Skip to main content

vector_core/community/v2/
service.rs

1//! Concord v2 service — the stateful orchestration binding the pure v2 modules
2//! to storage + transport. Free functions, `std::sync::Arc<crate::db::Session>`-gated at every write
3//! (a `swap_session` can land at any await — see CLAUDE.md), mirroring the v1
4//! service's discipline.
5//!
6//! Signing + NIP-44 flow through the active [`VectorSigner`] (`active_signer()`):
7//! the live client's signer for a NIP-46 bunker / NIP-55 offline account, else the
8//! local vault. Every identity op in v2 is `sign_event` / `nip44_encrypt` /
9//! `nip44_decrypt` — a remote signer's whole surface — so create, send, join,
10//! invite, moderate, rotate, and refound all work keylessly (CORD-06 D1/D5 made the
11//! rekey locator public + its blobs pairwise NIP-44, so unlike v1 there is no
12//! raw-ECDH exception).
13
14use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
15
16use super::super::transport::{Query, Transport};
17use super::super::{version, ChannelId, Epoch};
18use super::chat::{self, ChatEvent};
19use super::community::{ChannelV2, CommunityV2};
20use super::control;
21use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, GroupKey};
22use super::invite::{self, CommunityInvite};
23use super::rekey::{self, Continuity, RekeyScope};
24use super::{guestbook, stream, vsk};
25use crate::community::edition::ParsedEdition;
26
27/// The active signer for v2 authority actions: the live client's signer — which
28/// covers a NIP-46 bunker / NIP-55 offline signer — falling back to the local
29/// vault keys when there is no client or no signer attached (local accounts,
30/// headless/CLI paths, and tests). Every v2 seal, rekey blob, and control edition
31/// signs / NIP-44-wraps through this, so a keyless account can create AND
32/// administer a community. v2's rekey locator is public + its blobs are pairwise
33/// NIP-44 (CORD-06 D1/D5), so unlike v1 there is no raw-ECDH exception.
34/// The active identity's public key for addressing/tags — authoritative (set at
35/// login), no signer round-trip. Used everywhere v2 needs "who am I" so a keyless
36/// account (empty vault) still resolves its own identity.
37fn me_pk() -> Result<PublicKey, String> {
38    crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
39}
40
41fn now_ms() -> u64 {
42    std::time::SystemTime::now()
43        .duration_since(std::time::UNIX_EPOCH)
44        .map(|d| d.as_millis() as u64)
45        .unwrap_or(0)
46}
47
48/// `now_ms`, but never twice the same value in this process.
49///
50/// Ordering is by the `ms` tag, and two sends can finish inside one millisecond
51/// — so a sender's own back-to-back messages carried IDENTICAL stamps and fell
52/// through to the reader's tiebreak, which is content-derived and therefore
53/// indifferent to which was typed first. Bumping past the last stamp keeps a
54/// single sender's sequence self-consistent; readers still tiebreak across
55/// senders. Drifts at most a few ms ahead of the clock under a burst, and only
56/// until the clock catches up.
57fn next_send_ms() -> u64 {
58    use std::sync::atomic::{AtomicU64, Ordering};
59    static LAST: AtomicU64 = AtomicU64::new(0);
60    let now = now_ms();
61    LAST.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| Some(now.max(last + 1)))
62        .map(|last| now.max(last + 1))
63        .unwrap_or(now)
64}
65
66/// Create a fresh v2 community owned by the local identity: mint the genesis
67/// (self-certifying id + the two owner editions), persist, publish the genesis
68/// control editions, and announce the owner's Guestbook Join. Returns the saved
69/// community.
70pub async fn create_community<T: Transport + ?Sized>(
71    transport: &T,
72    name: &str,
73    relays: Vec<String>,
74    description: Option<String>,
75) -> Result<CommunityV2, String> {
76    crate::db::scoped(async move {
77        let signer = crate::signer::active_signer()?;
78        let owner_pk = me_pk()?;
79        let at_ms = now_ms();
80
81        let meta = control::CommunityMetadata {
82            name: name.to_string(),
83            description: description.clone(),
84            relays: relays.clone(),
85            ..Default::default()
86        };
87        let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
88        let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
89
90        // Save-before-publish (like v1 create): no peers exist yet so there's no
91        // shared view to diverge from, and the fresh-random keys are irrecoverable
92        // if a publish hiccup rolled them back. Re-check the session after the genesis
93        // signing await (a bunker signs over the network) before the DB write.
94        // Seed the genesis edition heads (v1) as the owner's refuse-downgrade floor, so a
95        // later edit can't be rolled back by a relay serving only the genesis prefix. The
96        // live control sub is replay-free (limit 0), so the owner won't re-fold its own
97        // genesis to seed the floor otherwise. Floors land BEFORE the community row
98        // (floors-then-state ordering).
99        let control = control::ControlPlane::of(&community);
100        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
101        for wrap in &genesis.wraps {
102            if let Ok((ed, _)) = control.open(wrap) {
103                let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
104                crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
105            }
106        }
107        crate::db::community::save_community_v2(&community)?;
108        // Archive the genesis root at epoch 0, so a later Refounding leaves this epoch's
109        // Public-channel history readable (CORD-03 §3 multi-epoch read).
110        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
111
112        // Publish the two genesis control editions at the epoch-0 control plane.
113        // Durable, not single-shot: over a slow transport (Tor) one attempt is a coin
114        // flip, and a lost genesis leaves a community that exists only locally. Durable
115        // races every relay, returns on the first ACK, then heals stragglers in the bg.
116        for wrap in &genesis.wraps {
117            transport.publish_durable(wrap, &community.relays).await?;
118        }
119
120        // Announce the owner's Guestbook Join so they appear in the memberlist. Relays are
121        // proven-alive by the genesis ACK above, so durable here just guarantees the owner's
122        // own join lands (member count) without a real block risk.
123        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
124        let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
125        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
126            let _ = transport.publish_durable(&join_wrap, &community.relays).await;
127        }
128
129        // Sync the new membership across devices (CORD-02 §8), durably — see the join path.
130        match republish_community_list(transport, Some(community.id())).await {
131            Ok(true) => {}
132            Ok(false) => republish_community_list_durable(Some(*community.id())),
133            Err(e) => {
134                crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
135                republish_community_list_durable(Some(*community.id()));
136            }
137        }
138        Ok(community)
139    })
140    .await
141}
142
143/// Mint a v2 migration TWIN whose primary channel REUSES the v1 primary channel id (§migration)
144/// so chat history stitches through the flip. Same owner identity, fresh salt/root. Additional
145/// v1 channels are added by the wizard via `create_*_channel_with_id`. Mirrors
146/// [`create_community`]'s persist-before-publish + floor seeding.
147pub async fn create_migration_twin<T: Transport + ?Sized>(
148    transport: &T,
149    name: &str,
150    relays: Vec<String>,
151    description: Option<String>,
152    primary: (ChannelId, String),
153) -> Result<CommunityV2, String> {
154    crate::db::scoped(async move {
155        let signer = crate::signer::active_signer()?;
156        let owner_pk = me_pk()?;
157        let at_ms = now_ms();
158
159        let meta = control::CommunityMetadata {
160            name: name.to_string(),
161            description: description.clone(),
162            relays: relays.clone(),
163            ..Default::default()
164        };
165        let primary_name = primary.1.clone();
166        let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
167            .await
168            .map_err(|e| e.to_string())?;
169        let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
170        // from_genesis hard-names the primary "general"; carry the v1 name (the wire edition
171        // already carries it, so this only keeps the owner's immediate local view correct).
172        if let Some(ch) = community.channels.first_mut() {
173            ch.name = primary_name;
174        }
175        let control = control::ControlPlane::of(&community);
176        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
177        for wrap in &genesis.wraps {
178            if let Ok((ed, _)) = control.open(wrap) {
179                let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
180                crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
181            }
182        }
183        crate::db::community::save_community_v2(&community)?;
184        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
185        for wrap in &genesis.wraps {
186            transport.publish_durable(wrap, &community.relays).await?;
187        }
188        // Owner Guestbook Join so they appear in the twin's memberlist.
189        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
190        let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
191        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
192            let _ = transport.publish_durable(&join_wrap, &community.relays).await;
193        }
194        Ok(community)
195    })
196    .await
197}
198
199/// Clone a v1 banlist onto the v2 twin (§migration Phase 1.3): the join-time ban gate needs
200/// the v2 banlist to name every v1-banned npub, else a banned-but-never-cut member who can
201/// open `m` would walk in. Owner-signed on the twin's control plane.
202pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
203    transport: &T,
204    twin: &CommunityV2,
205    banned: &[String],
206) -> Result<(), String> {
207    if banned.is_empty() {
208        return Ok(());
209    }
210    set_banlist(transport, twin, banned).await
211}
212
213/// Clone v1 governance onto the twin (§migration Phase 1.3): every v1 member who was a FULL
214/// admin (effective permissions ⊇ ADMIN_ALL) is re-granted @admin on the twin (mapping v1's
215/// Admin onto v2's deterministic admin role id, CORD-04 §2). The owner is supreme by
216/// identity (never a grant) and banned members are skipped (a banned author's editions fold
217/// out anyway, and re-granting would spring them back to admin on a future unban).
218///
219/// NON-ESCALATION: only a full admin maps to v2 @admin (which holds ADMIN_ALL). A
220/// partial-management v1 role holder (e.g. CREATE_INVITE only — never minted by the v1 UI,
221/// but reachable via the SDK) degrades to a plain member rather than being ESCALATED to full
222/// admin. Bespoke non-admin custom roles are not carried — a documented, non-security gap.
223pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
224    transport: &T,
225    twin: &CommunityV2,
226    v1_roles: &crate::community::roles::CommunityRoles,
227    banned: &[String],
228) -> Result<(), String> {
229    use crate::community::roles::Permissions;
230    let owner = twin.owner()?;
231    for grant in &v1_roles.grants {
232        // Founding mask: v1 admin roles predate PIN_MESSAGES, so requiring the
233        // widened ADMIN_ALL would silently demote every migrating v1 admin.
234        if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_FOUNDING_MASK) {
235            continue; // not a full admin → plain member on v2 (never escalated)
236        }
237        if banned.contains(&grant.member) {
238            continue; // banned → no authority on v2, don't re-arm a future unban
239        }
240        let Ok(member) = PublicKey::parse(&grant.member) else { continue };
241        if member == owner {
242            continue; // supreme by identity — never needs a grant
243        }
244        grant_admin(transport, twin, &member).await?;
245    }
246    Ok(())
247}
248
249/// The twin's JoinMaterial — the membership subset sealed into the migration `m`.
250/// The `m` goes to every migrating MEMBER, so the owner's staff write secret is
251/// stripped: members get the address (read), never the key (CORD-02 §2).
252pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
253    let mut jm = join_material(twin);
254    jm.control_root = None;
255    jm
256}
257
258/// Send a text message to a channel. Derives the channel's Chat-Plane group key
259/// (community_root for a Public channel, the channel key for a Private one),
260/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
261pub async fn send_message<T: Transport + ?Sized>(
262    transport: &T,
263    community: &CommunityV2,
264    channel_id: &ChannelId,
265    content: &str,
266) -> Result<String, String> {
267    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
268}
269
270/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
271/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
272/// attachments). Returns the message's rumor id (hex).
273pub async fn send_chat_message<T: Transport + ?Sized>(
274    transport: &T,
275    community: &CommunityV2,
276    channel_id: &ChannelId,
277    content: &str,
278    reply_to: Option<(&str, &str)>,
279    emoji: &[(&str, &str)],
280    extra_tags: Vec<nostr_sdk::prelude::Tag>,
281) -> Result<String, String> {
282    send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, next_send_ms()).await
283}
284
285/// [`send_chat_message`] with an explicit event time. The rumor id is a pure
286/// function of its inputs, so a GUI that picks `at_ms` can precompute the id for
287/// its optimistic pending row — the in-process echo and the finalize then key
288/// the SAME id (the exact v1 pending → sent contract).
289#[allow(clippy::too_many_arguments)]
290pub async fn send_chat_message_at<T: Transport + ?Sized>(
291    transport: &T,
292    community: &CommunityV2,
293    channel_id: &ChannelId,
294    content: &str,
295    reply_to: Option<(&str, &str)>,
296    emoji: &[(&str, &str)],
297    extra_tags: Vec<nostr_sdk::prelude::Tag>,
298    at_ms: u64,
299) -> Result<String, String> {
300    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
301    let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
302    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
303}
304
305/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
306/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
307/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
308/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
309#[allow(clippy::too_many_arguments)]
310pub async fn send_reaction<T: Transport + ?Sized>(
311    transport: &T,
312    community: &CommunityV2,
313    channel_id: &ChannelId,
314    target_id_hex: &str,
315    target_author_hex: &str,
316    target_kind: u16,
317    emoji_content: &str,
318    emoji: Option<(&str, &str)>,
319) -> Result<String, String> {
320    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
321    let at_ms = next_send_ms();
322    let rumor =
323        chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
324    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
325}
326
327/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
328/// with the replacement text. Author-enforced on the read side — only the
329/// original author's edit folds.
330pub async fn send_edit<T: Transport + ?Sized>(
331    transport: &T,
332    community: &CommunityV2,
333    channel_id: &ChannelId,
334    target_id_hex: &str,
335    new_content: &str,
336) -> Result<String, String> {
337    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
338    let at_ms = next_send_ms();
339    let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
340    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
341}
342
343/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
344/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
345/// retained per-message stream key (see `publish_chat`).
346pub async fn send_delete<T: Transport + ?Sized>(
347    transport: &T,
348    community: &CommunityV2,
349    channel_id: &ChannelId,
350    target_id_hex: &str,
351    target_kind: u16,
352) -> Result<String, String> {
353    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
354    let at_ms = next_send_ms();
355    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
356    let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
357    // §7: deleting one's own pinned message obliges the immediate omitting
358    // edition. Hooked HERE and not only at ingest — the local delete drops the
359    // row before the relay echo returns, so the echo can't re-authorize and
360    // the ingest hook never sees an own delete.
361    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
362    spawn_pin_duty(&ch_hex, target_id_hex, None);
363    Ok(id)
364}
365
366/// Moderation-hide: remove SOMEONE ELSE's message under `MANAGE_MESSAGES`
367/// (CORD-04 §3/§5). Same kind-5 the author's own delete uses — CORD defines no
368/// separate hide, the authority is what differs, and every reader re-derives it
369/// from the seal's real npub against the folded Roster.
370///
371/// Gated locally against the same predicate peers enforce, so the button can't
372/// promise what the plane will refuse; a non-owner cites the Grant it acts under.
373/// `target_author` comes from the caller's resident copy — you can only moderate
374/// a message you can see.
375pub async fn moderation_delete<T: Transport + ?Sized>(
376    transport: &T,
377    community: &CommunityV2,
378    channel_id: &ChannelId,
379    target_id_hex: &str,
380    target_kind: u16,
381    target_author: &PublicKey,
382) -> Result<String, String> {
383    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
384    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
385    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
386        return Err("this community is dissolved — it accepts no new moderation actions".to_string());
387    }
388    let owner_hex = community.owner()?.to_hex();
389    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
390    if !crate::community::moderation::can_hide(
391        Some(&owner_hex),
392        &roster,
393        &author_pk.to_hex(),
394        &target_author.to_hex(),
395    ) {
396        return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
397    }
398    let at_ms = next_send_ms();
399    let citation = required_authority_citation(community, &author_pk)?;
400    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
401    let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
402    // §7: a moderation-hide of a pinned message obliges the omission too, and
403    // the moderator here provably holds the bit's neighbourhood (MANAGE_MESSAGES
404    // curators usually hold PIN_MESSAGES); the duty itself re-checks.
405    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
406    spawn_pin_duty(&ch_hex, target_id_hex, None);
407    Ok(id)
408}
409
410/// WebXDC realtime peer signal (kind 3310) — the v2 twin of v1's
411/// `publish_webxdc_signal`: the same shared content shape, sealed on the
412/// channel's chat plane, DURABLE (a reopening peer backfills a recent ad).
413/// Signed by the member's real identity — a member can't forge another
414/// player's presence. Failure is non-fatal to callers (the next re-advertise
415/// covers a missed ad).
416pub async fn send_webxdc_signal<T: Transport + ?Sized>(
417    transport: &T,
418    community: &CommunityV2,
419    channel_id: &ChannelId,
420    topic_id: &str,
421    node_addr: Option<&str>,
422) -> Result<(), String> {
423    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
424    let at_ms = now_ms();
425    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
426    let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
427    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
428}
429
430/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
431pub async fn send_typing<T: Transport + ?Sized>(
432    transport: &T,
433    community: &CommunityV2,
434    channel_id: &ChannelId,
435) -> Result<(), String> {
436    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
437    let at_ms = now_ms();
438    let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
439    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
440}
441
442/// Everything a chat-plane send needs: local keys, the channel's group key +
443/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
444/// community (every honest member sealed it read-only) and a keyless Private
445/// channel — deriving from the root would post to the public plane; its key
446/// arrives over the rekey plane.
447fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch), String> {
448    let author_pk = me_pk()?;
449    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
450    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
451        return Err("this community has been dissolved".to_string());
452    }
453    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
454    // echo would silently no-op, so fail loudly instead of a message that seems
455    // to send but shows up nowhere.
456    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
457        return Err("you are banned from this community".to_string());
458    }
459    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
460    if ch.private && ch.key.is_none() {
461        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
462    }
463    let (secret, epoch) = community.channel_secret(ch);
464    Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch))
465}
466
467/// Seal one chat rumor, publish, and echo the send into the
468/// shared store. Returns the rumor id (hex).
469#[allow(clippy::too_many_arguments)]
470/// §7: an OWN edit must refresh its pinned target's proof bundle from the SEND
471/// side — the relay echo of an own send deduplicates against the local echo, so
472/// the ingest hook never sees it (send_delete's omission duty, same reason).
473/// Without this the author's pin lags behind their message while everyone
474/// else's follows. Returns the duty's arguments when the echo applied an edit.
475fn own_echo_pin_duty(
476    outcome: &super::inbound::ChatPersist,
477    event: &ChatEvent,
478) -> Option<(String, super::stream::OpenedStream)> {
479    match (outcome, event) {
480        (super::inbound::ChatPersist::Updated { .. }, ChatEvent::Edit { opened, target, .. }) => {
481            Some((crate::simd::hex::bytes_to_hex_32(target), opened.clone()))
482        }
483        _ => None,
484    }
485}
486
487async fn publish_chat<T: Transport + ?Sized>(
488    transport: &T,
489    community: &CommunityV2,
490    group: &GroupKey,
491    author_pk: PublicKey,
492    channel_id: &ChannelId,
493    epoch: Epoch,
494    rumor: nostr_sdk::prelude::UnsignedEvent,
495    at_ms: u64,
496    ephemeral: bool,
497) -> Result<String, String> {
498    crate::db::scoped(async move {
499        let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
500        let signer = crate::signer::active_signer()?;
501        let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
502            .map_err(|e| e.to_string())?;
503        transport.publish(&wrap, &community.relays).await?;
504        // Retain the wrap's signing key (the group stream key) keyed by rumor id so a
505        // full delete can NIP-09 this exact wrap off relays (same-author rule, honored
506        // everywhere — the discarded p-tag pair only works on recipient-delete relays).
507        // Frozen per-message so later rekeys can't strand it. Session-gated: the publish
508        // straddled network I/O.
509        if !ephemeral {
510            crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
511        }
512        // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
513        // send-then-read works with no listen loop, and the relay's re-delivery dedups
514        // against this row instead of re-firing callbacks. Best-effort — the publish
515        // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
516        if !ephemeral {
517            if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
518                let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
519                let outcome = {
520                    let mut st = crate::state::STATE.lock().await;
521                    super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
522                };
523                if let Some(outcome) = outcome {
524                    super::inbound::persist_chat(&channel_hex, &outcome).await;
525                    if let Some((target_hex, opened)) = own_echo_pin_duty(&outcome, &event) {
526                        spawn_pin_duty(&channel_hex, &target_hex, Some(opened));
527                    }
528                }
529            }
530        }
531        Ok(rumor_id)
532    })
533    .await
534}
535
536/// A chat event opened from a channel fetch, tagged with the epoch its key
537/// decrypted under.
538pub struct FetchedEvent {
539    pub event: ChatEvent,
540    pub epoch: Epoch,
541}
542
543/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
544/// pre-retention and other-device sends stay fully deletable, because the wrap's
545/// signing key is the derivable group stream key — only this rumor→wrap mapping
546/// was ever missing locally. No-op for foreign authors, kinds the UI can't
547/// delete, and already-retained rows. Best-effort: a store failure never breaks
548/// the fetch.
549fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
550    if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
551        return;
552    }
553    let opened = event.opened();
554    if crate::state::my_public_key() != Some(opened.author) {
555        return;
556    }
557    let rumor_hex = opened.rumor_id.to_hex();
558    // Only fill a confirmed gap — never clobber a send-time row, never write
559    // when the store can't be read.
560    if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
561        return;
562    }
563    if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
564        // The UI caches full-vs-limited delete verdicts per message; tell it this
565        // one just flipped so it re-resolves without an app restart.
566        crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
567    }
568}
569
570/// How many of the newest held epochs a newest-first catch-up reads. One: a
571/// rotation moves the conversation, so the live epoch is where it is. Rotated-past
572/// epochs are reached by back-paging, when a reader actually scrolls for them.
573const LIVE_COORD_EPOCHS: usize = 1;
574
575/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
576/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
577/// per epoch; deeper history pages backwards via the walk.
578pub async fn fetch_channel<T: Transport + ?Sized>(
579    transport: &T,
580    community: &CommunityV2,
581    channel_id: &ChannelId,
582    limit: usize,
583) -> Result<Vec<FetchedEvent>, String> {
584    fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
585}
586
587/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
588/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
589/// address one `page`-sized query at a time until `max_pages`, a drained relay,
590/// or `keep_paging` returns false for a page (the caller's "I already hold
591/// these" early stop — consulted only on pages that opened something, so junk
592/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
593/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
594/// full page of only-already-seen wraps is a same-second WALL (relay filters
595/// are second-granular) and steps past it accepting that unseen same-second
596/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
597/// limitation (the `ms` tag can't be filtered server-side).
598///
599/// Returns everything opened, deduped by rumor id, oldest→newest.
600pub async fn fetch_channel_history<T: Transport + ?Sized>(
601    transport: &T,
602    community: &CommunityV2,
603    channel_id: &ChannelId,
604    page: usize,
605    max_pages: usize,
606    since: Option<u64>,
607    // Unix-seconds upper bound for the FIRST page (inclusive) — the back-paging
608    // cursor. `None` starts at the newest.
609    start_until: Option<u64>,
610    evidence: crate::community::transport::Evidence,
611    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
612) -> Result<Vec<FetchedEvent>, String> {
613    crate::db::scoped(async move {
614        // Guards the opportunistic scrub-key heals below — the fetch loop straddles
615        // network I/O, and an account swap must not write into the new account's DB.
616        let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
617        // A Public channel reads across EVERY held base-root epoch, and a Private one
618        // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
619        // continuous either way. A keyless Private channel is unreadable — never derived
620        // from the root (that would address the public plane).
621        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
622        let coords: Vec<([u8; 32], Epoch)> = if ch.private {
623            let Some(current) = ch.key else {
624                return Ok(Vec::new());
625            };
626            let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
627            let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
628            if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
629                held.push((ch.epoch, current));
630            }
631            // Only real grants are archived, but keep the invariant local: a private
632            // plane is never read with the root value.
633            held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
634        } else {
635            let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
636            if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
637                roots.push((community.root_epoch, community.community_root));
638            }
639            roots.into_iter().map(|(ep, root)| (root, ep)).collect()
640        };
641        if coords.is_empty() {
642            return Ok(Vec::new());
643        }
644        // Every epoch is its own address on this plane, so the full set costs one
645        // fetch per held epoch per page and grows with the community's whole
646        // rotation history. A newest-first catch-up wants the live epoch, which is
647        // the only one anyone should be speaking on; reaching back through a
648        // rotation is what back-paging (`start_until`) is for — the same division
649        // the boot volley already draws.
650        let mut coords = coords;
651        if start_until.is_none() && coords.len() > LIVE_COORD_EPOCHS {
652            coords.sort_by_key(|(_, e)| std::cmp::Reverse(e.0));
653            coords.truncate(LIVE_COORD_EPOCHS);
654        }
655
656        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
657        let mut seen_rumors = std::collections::HashSet::new();
658        let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
659        let mut until: Option<u64> = start_until;
660        let mut oldest: Option<u64> = None;
661        for _ in 0..max_pages {
662            // Fetch each held epoch's Chat-Plane AUTHED AS that plane key. AUTH-gating
663            // relays (Ditto) require the connection authed as the author queried and
664            // reject a multi-author REQ ("all authors must be authenticated"), so a
665            // single merged fetch returns nothing there — the latest messages under a
666            // freshly-adopted epoch never load. Per-plane authed fetches + union.
667            let mut wraps: Vec<Event> = Vec::new();
668            let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
669            for (secret, epoch) in &coords {
670                let plane = channel_group_key(secret, channel_id, *epoch);
671                let q = Query {
672                    kinds: vec![stream::KIND_WRAP],
673                    authors: vec![plane.pk_hex()],
674                    since,
675                    until,
676                    limit: Some(page),
677                    evidence,
678                    ..Default::default()
679                };
680                if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
681                    for e in evs {
682                        if wrap_ids.insert(e.id) {
683                            wraps.push(e);
684                        }
685                    }
686                }
687            }
688            if wraps.is_empty() {
689                break;
690            }
691            let mut fresh = 0usize;
692            let mut page_events: Vec<FetchedEvent> = Vec::new();
693            for wrap in &wraps {
694                if !seen_wraps.insert(wrap.id) {
695                    continue;
696                }
697                fresh += 1;
698                let at = wrap.created_at.as_secs();
699                if oldest.is_none_or(|o| at < o) {
700                    oldest = Some(at);
701                }
702                // Select the epoch whose group key authored this wrap (no trial decrypt).
703                for (secret, epoch) in &coords {
704                    let group = channel_group_key(secret, channel_id, *epoch);
705                    if wrap.pubkey != group.pk() {
706                        continue;
707                    }
708                    if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
709                        let id = event.opened().rumor_id;
710                        if seen_rumors.insert(id) {
711                            heal_own_wrap_key(&event, &group, &community.relays);
712                            page_events.push(FetchedEvent { event, epoch: *epoch });
713                        }
714                    }
715                    break;
716                }
717            }
718            if fresh == 0 {
719                if wraps.len() < page {
720                    break; // drained — the relay has nothing older.
721                }
722                // A full page of already-seen wraps: a same-second WALL. Step past it;
723                // same-second siblings beyond the relay's cap are unreachable by a
724                // second-granular filter.
725                let Some(o) = oldest else { break };
726                if o == 0 {
727                    break;
728                }
729                crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
730                until = Some(o - 1);
731                continue;
732            }
733            let stop = !page_events.is_empty() && !keep_paging(&page_events);
734            out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
735            if stop {
736                break; // the caller holds everything from here back.
737            }
738            until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
739        }
740        // Ties break on the content-derived rumor id, so every client orders a
741        // same-millisecond pair identically. A bare `ms` sort left ties to whatever
742        // order the relay happened to serve across pages, so two readers could show
743        // the same two messages in opposite orders.
744        out.sort_by(|a, b| {
745            a.0.cmp(&b.0)
746                .then_with(|| a.1.event.opened().rumor_id.as_bytes().cmp(b.1.event.opened().rumor_id.as_bytes()))
747        });
748        Ok(out.into_iter().map(|(_, e)| e).collect())
749    })
750    .await
751}
752
753// ── Invites (CORD-05) ────────────────────────────────────────────────────────
754
755/// Who an invite bundle is FOR — which decides the Private-Channel keys it may
756/// carry (CORD-05 §1 vs §2).
757///
758/// A **Link** has no recipient: "anyone the link reaches can join", so its
759/// audience holds no Role by construction and is entitled to no Private Channel
760/// at all. A **Member** is a specific npub whose entitlement is computable.
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub enum BundleAudience {
763    /// A public link (33301 bundle event): public channels only.
764    Link,
765    /// A direct invite (3313) to this npub: may carry Private-Channel keys.
766    Member(PublicKey),
767}
768
769/// Build the §1 invite bundle for this community, scoped to `audience`. A
770/// Public channel carries the `community_root` as its "key" (the joiner derives
771/// the real secret from the root); a Private one its own key — and only for a
772/// Member the folded roster shows entitled. The bundle self-certifies the owner,
773/// so the inviter's identity is irrelevant to trust.
774pub fn bundle_of(
775    community: &CommunityV2,
776    audience: BundleAudience,
777    creator: Option<PublicKey>,
778    expires_at_ms: Option<u64>,
779    label: Option<String>,
780) -> CommunityInvite {
781    bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
782}
783
784/// [`bundle_of`] settling entitlement against a Grant this client JUST published
785/// (`with`/`without` role ids), since the fold lags its own publish. This is the
786/// grant-vend path (CORD-03 "delivered on grant").
787pub fn bundle_of_with_overlay(
788    community: &CommunityV2,
789    audience: BundleAudience,
790    creator: Option<PublicKey>,
791    expires_at_ms: Option<u64>,
792    label: Option<String>,
793    with: &[String],
794    without: &[String],
795) -> CommunityInvite {
796    let hex = crate::simd::hex::bytes_to_hex_32;
797    let cid_hex = hex(&community.identity.community_id.0);
798    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
799    let owner_hex = community.owner().ok().map(|o| o.to_hex());
800    let recipient_hex = match audience {
801        BundleAudience::Link => None,
802        BundleAudience::Member(pk) => Some(pk.to_hex()),
803    };
804    let channels = community
805        .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
806        .into_iter()
807        .map(|c| invite::ChannelGrant {
808            id: hex(&c.id.0),
809            key: hex(&c.key.unwrap_or(community.community_root)),
810            epoch: c.epoch.0,
811            name: c.name.clone(),
812        })
813        .collect();
814    CommunityInvite {
815        community_id: hex(&community.identity.community_id.0),
816        owner: hex(&community.identity.owner_xonly),
817        owner_salt: hex(&community.identity.owner_salt),
818        community_root: hex(&community.community_root),
819        root_epoch: community.root_epoch.0,
820        // Read access to the Control Plane, never write (CORD-02 §7); absent
821        // on a legacy pre-split epoch.
822        control_pk: community.control_pk.map(|p| p.to_hex()),
823        channels,
824        relays: community.relays.clone(),
825        name: community.name.clone(),
826        // Mint-time snapshot so a parked invite renders the real logo before any
827        // fold; the Control Plane stays the authority after joining.
828        icon: community.icon.clone(),
829        expires_at: expires_at_ms,
830        creator_npub: creator.map(|p| p.to_hex()),
831        label,
832        extra: Default::default(),
833    }
834}
835
836/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
837/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
838/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
839/// bundle hands over the keys; the recipient consents by accepting (nothing joins
840/// on receipt). Returns the wrap.
841pub async fn send_direct_invite<T: Transport + ?Sized>(
842    transport: &T,
843    community: &CommunityV2,
844    recipient: &PublicKey,
845    expires_at_ms: Option<u64>,
846    label: Option<String>,
847) -> Result<Event, String> {
848    crate::db::scoped(async move {
849        // A stale bundle is worse than a stale edit: it hands the joiner keys to a
850        // buried epoch, and their client later self-evicts on the rekey exclusion.
851        assert_current_root(community)?;
852        let signer = crate::signer::active_signer()?;
853        let inviter_pk = me_pk()?;
854        let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
855        let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
856        transport.publish(&wrap, &community.relays).await?;
857        Ok(wrap)
858    })
859    .await
860}
861
862/// A minted public link: the shareable URL plus the addressable bundle event to
863/// publish and the link keypair to retain (in the Invite List) for later refresh
864/// or revocation.
865pub struct MintedLink {
866    pub url: String,
867    pub bundle_event: Event,
868    pub link_signer: Keys,
869    pub token: [u8; super::derive::TOKEN_LEN],
870    /// Unix ms, mirrored from the bundle. The Invite List is the creator's only
871    /// record of it, and the Registry prunes on it — the coordinate a member
872    /// folds carries no expiry, so a lapsed link the creator never pruned reads
873    /// as a live door forever (CORD-05 §4/§5).
874    pub expires_at_ms: Option<u64>,
875    pub label: Option<String>,
876}
877
878/// Mint a public invite link for this community: a fresh token + link keypair, the
879/// bundle encrypted under the token key and published at `(33301, link_signer,
880/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
881/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
882/// relays and never reaches a server.
883pub async fn mint_public_link<T: Transport + ?Sized>(
884    transport: &T,
885    community: &CommunityV2,
886    base: &str,
887    expires_at_ms: Option<u64>,
888    label: Option<String>,
889) -> Result<MintedLink, String> {
890    crate::db::scoped(async move {
891        // Readers ignore a registry whose author lacks CREATE_INVITE (it can't even
892        // flip the community Public) — refuse before minting a link nobody honors.
893        ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::CREATE_INVITE, "minting a public invite link")?;
894        let mut token = [0u8; super::derive::TOKEN_LEN];
895        token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
896        let link_signer = Keys::generate();
897        let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
898        let bundle_key = super::derive::invite_bundle_key(&token);
899        let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
900        let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
901
902        transport.publish_durable(&bundle_event, &community.relays).await?;
903        let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
904        // Sync the link across the creator's devices (13303) + publish the Registry
905        // (vsk-8) so members see the community is Public. Best-effort — the link works
906        // without the sync.
907        let _ = record_minted_link(transport, community, &minted).await;
908        // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
909        // the 13303 list remains the cross-device record. Re-check the session: the
910        // publishes above straddled awaits, and this write must not land account A's
911        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
912        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
913        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
914        Ok(minted)
915    })
916    .await
917}
918
919// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
920
921/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
922/// decrypt/parse failure is "no news", never a clobber of the local mirror).
923/// Transport failure is Err, NOT None: the 13303 is REPLACEABLE, so a caller
924/// that mistakes "couldn't reach the relays" for "no list yet" and publishes a
925/// fresh one wipes every link minted on other devices. Full evidence for the
926/// same reason — this read feeds replaceable-event writes.
927async fn fetch_invite_list<T: Transport + ?Sized>(
928    transport: &T,
929    relays: &[String],
930) -> Result<Option<invite::InviteList>, String> {
931    let signer = crate::signer::active_signer()?;
932    let my_pk = me_pk()?;
933    let query = Query {
934        kinds: vec![super::kind::INVITE_LIST],
935        authors: vec![my_pk.to_hex()],
936        limit: Some(4),
937        evidence: crate::community::transport::Evidence::Full,
938        ..Default::default()
939    };
940    let events = transport.fetch(&query, relays).await?;
941    let mut best: Option<(u64, invite::InviteList)> = None;
942    for e in events {
943        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
944            let at = e.created_at.as_secs();
945            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
946                best = Some((at, l));
947            }
948        }
949    }
950    Ok(best.map(|(_, l)| l))
951}
952
953/// The creator's LIVE link-signer pubkeys for one community — the Registry's
954/// content (CORD-05 §5), derived from the stored link secrets.
955///
956/// Live means neither tombstoned nor EXPIRED. An expired link cannot be joined
957/// (`InviteBundle::expired`, CORD-05 §1), so leaving it in the Registry states
958/// a door that isn't there: the aggregate never empties, the community reads
959/// Public forever, and every gate hanging off that reading silently inverts.
960fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
961    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
962    list.entries
963        .iter()
964        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
965        .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
966        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
967        .collect()
968}
969
970/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
971/// community — so members fold it into the Public/Private source of truth (a
972/// non-empty aggregate = Public).
973async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, __session: &std::sync::Arc<crate::db::Session>, live_signers: &[PublicKey]) -> Result<(), String> {
974    let my_pk = me_pk()?;
975    let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
976    let content = invite::build_registry_content(live_signers);
977    publish_control_edition(transport, community, vsk::INVITE_LINKS, &eid, &content).await?;
978    // Refresh the cache from the PLANE, not from `live_signers`: the column aggregates
979    // every creator, so writing only mine would clobber theirs, and a union could never
980    // shrink — retiring the last link would leave the community reading Public forever.
981    refresh_invite_registry_cache(transport, community).await;
982    Ok(())
983}
984
985/// Re-fold the whole invite Registry and cache it, so Public/Private stays a sync
986/// LOCAL read. Silent no-op when the plane can't be read whole — a partial fold
987/// would under-state Public, leaving a live link open behind a ban.
988async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
989    crate::db::scoped(async move {
990        let Ok(owner) = community.owner() else { return };
991        let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
992        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
993        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
994            .unwrap_or_default()
995            .into_iter()
996            .filter(|(_, f)| f.0 == community.root_epoch.0)
997            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
998            .collect();
999        let authority = fold_authority(community, &editions, &floors);
1000        let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
1001        let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
1002        let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
1003    })
1004    .await
1005}
1006
1007/// Record a freshly-minted public link across the creator's devices: append it to the
1008/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
1009async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
1010    crate::db::scoped(async move {
1011        let session = crate::db::current_session();
1012        let signer = crate::signer::active_signer()?;
1013        let my_pk = me_pk()?;
1014        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1015        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
1016        // Err aborts the sync half (the link's bundle already published durably;
1017        // a retry re-records it) — an unreachable relay set must never be mistaken
1018        // for "no list yet" and clobber the replaceable 13303. Ok(None) IS a fresh
1019        // creator's honest first list.
1020        let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
1021        if !list.entries.iter().any(|e| e.token == token_hex) {
1022            list.entries.push(invite::InviteEntry {
1023                token: token_hex,
1024                signer_sk: minted.link_signer.secret_key().to_secret_hex(),
1025                community_id: cid_hex.clone(),
1026                url: minted.url.clone(),
1027                label: minted.label.clone(),
1028                created_at: now_ms() / 1000,
1029                expires_at: minted.expires_at_ms,
1030                extra: Default::default(),
1031            });
1032        }
1033        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1034        transport.publish(&event, &community.relays).await?;
1035        let signers = live_signers_for(&list, &cid_hex, now_ms());
1036        publish_invite_registry(transport, community, &session, &signers).await
1037    })
1038    .await
1039}
1040
1041/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
1042/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
1043/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
1044/// LAST live link empties the Registry → the community reads Private (a Refounding is
1045/// the owner's separate read-cut).
1046pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
1047    crate::db::scoped(async move {
1048        let session = crate::db::current_session();
1049        let signer = crate::signer::active_signer()?;
1050        let my_pk = me_pk()?;
1051        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1052        let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1053        let entry = list
1054            .entries
1055            .iter()
1056            .find(|e| e.token == token_hex && e.community_id == cid_hex)
1057            .cloned()
1058            .ok_or("no such link in the invite list")?;
1059        // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
1060        let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1061        let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1062        transport.publish_durable(&revocation, &community.relays).await?;
1063        // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
1064        list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1065        list.entries.retain(|e| e.token != token_hex);
1066        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1067        transport.publish(&event, &community.relays).await?;
1068        let signers = live_signers_for(&list, &cid_hex, now_ms());
1069        // Does anyone ELSE still vend a live link? Folded BEFORE our own registry
1070        // republish: a fold taken after it would race relay propagation and read our
1071        // own stale (pre-revoke) registry, so the privatize decision would flip to
1072        // "still public" almost every time. Links are per-creator (CORD-05 §5), so
1073        // only the other creators' sets matter here — ours is `signers`.
1074        // (v1 parity: `revoke_public_invite`'s B1 fix refreshes the aggregate first
1075        // for exactly this reason.)
1076        let others_vend = if signers.is_empty() {
1077            match fetch_control_plane_whole(transport, community).await {
1078                Some(editions) => {
1079                    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1080                        .unwrap_or_default()
1081                        .into_iter()
1082                        .filter(|(_, f)| f.0 == community.root_epoch.0)
1083                        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1084                        .collect();
1085                    let owner_hex = community.owner().map(|o| o.to_hex()).unwrap_or_default();
1086                    let authority = fold_authority(community, &editions, &floors);
1087                    live_invite_link_sets(community.id(), &owner_hex, &editions, &authority, &floors)
1088                        .iter()
1089                        .any(|s| s.creator_hex != my_pk.to_hex())
1090                }
1091                // Unreadable plane: assume someone else still vends. Rotating on a
1092                // guess would burn an epoch (and strand pre-split clients) over a
1093                // transport blip; the community simply stays public until a read
1094                // that succeeds says otherwise.
1095                None => true,
1096            }
1097        } else {
1098            false
1099        };
1100        publish_invite_registry(transport, community, &session, &signers).await?;
1101        let _ = crate::db::community::delete_public_invite(token_hex);
1102        // CORD-06 §3: converting a Public Community to Private is a Refounding
1103        // trigger. Revoking only stops NEW acquisitions — everyone who already
1104        // fetched the bundle keeps the `community_root` until it rolls, so without
1105        // this the community reads Private while every past link-holder retains
1106        // read access forever. (v1 does this in `revoke_public_invite`; v2 shipped
1107        // without it.)
1108        //
1109        // Best-effort and retried, NEVER the revocation's verdict: the tombstone has
1110        // already landed, and reporting failure here would read as "the link is
1111        // still live" — the opposite of the truth — while a retry would fail anyway
1112        // (its list entry is gone). A Refounding needs BAN, so a link creator who
1113        // holds only CREATE_INVITE legitimately can't rotate; that surfaces as a
1114        // logged warning, not a broken revoke.
1115        if signers.is_empty() && !others_vend {
1116            let mut rotated = false;
1117            for attempt in 0..3u8 {
1118                match refound_community(transport, community, &[]).await {
1119                    Ok(_) => {
1120                        rotated = true;
1121                        break;
1122                    }
1123                    Err(e) if attempt == 2 => {
1124                        crate::log_warn!(
1125                            "[v2] privatizing {} could not rotate the base key ({e}); the community reads Private but everyone who took a link keeps read access until it rotates",
1126                            &cid_hex[..8.min(cid_hex.len())]
1127                        );
1128                        crate::emit_event(
1129                            "community_privatize_rotation_failed",
1130                            &serde_json::json!({ "community_id": cid_hex, "error": e }),
1131                        );
1132                    }
1133                    Err(_) => continue,
1134                }
1135            }
1136            if rotated {
1137                crate::log_info!("[v2] {} privatized: base key rotated so link-joined readers are cut off", &cid_hex[..8.min(cid_hex.len())]);
1138            }
1139        }
1140        Ok(())
1141    })
1142    .await
1143}
1144
1145/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
1146/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
1147/// coordinate with the CURRENT community state, so a link shared once keeps working
1148/// across rotations. Best-effort.
1149pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1150    crate::db::scoped(async move {
1151        let session = crate::db::current_session();
1152        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1153        // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
1154        // Err — the caller (a post-refounding refresh) must be able to retry, or live
1155        // links keep serving the PRE-refound root and new joiners land on the dead
1156        // epoch. A genuinely-empty list is Ok (nothing to refresh).
1157        let signer = crate::signer::active_signer()?;
1158        let my_pk = me_pk()?;
1159        let query = Query {
1160            kinds: vec![super::kind::INVITE_LIST],
1161            authors: vec![my_pk.to_hex()],
1162            limit: Some(4),
1163            ..Default::default()
1164        };
1165        let events = transport.fetch(&query, &community.relays).await?;
1166        let mut best: Option<(u64, invite::InviteList)> = None;
1167        for e in events {
1168            if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1169                let at = e.created_at.as_secs();
1170                if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1171                    best = Some((at, l));
1172                }
1173            }
1174        }
1175        let Some((_, list)) = best else {
1176            return Ok(());
1177        };
1178        let creator = my_pk;
1179        let now = now_ms();
1180        let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1181        for entry in &list.entries {
1182            if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1183                continue;
1184            }
1185            // An expired link can't be joined, so refreshing it just re-states a
1186            // door that isn't there (CORD-05 §1/§5).
1187            if entry.expires_at.is_some_and(|exp| now > exp) {
1188                continue;
1189            }
1190            let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1191            let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1192            let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1193            let bundle_key = super::derive::invite_bundle_key(&token);
1194            if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1195                let _ = transport.publish_durable(&event, &community.relays).await;
1196            }
1197        }
1198        // Republish the Registry from the same pruned view. Expiry is the one way a
1199        // link dies with no user action, so without a heal point here the aggregate
1200        // never empties and the community reads Public long after its last door
1201        // shut (CORD-05 §5). Idempotent when nothing lapsed.
1202        //
1203        // Only for a creator who actually minted here: one Invite List spans every
1204        // community, so a member holding links ELSEWHERE would otherwise publish an
1205        // empty Registry edition into this one on every rotation they adopt — a
1206        // control-plane write, and a version bump, for a coordinate they never owned.
1207        let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1208        if !mine_here {
1209            return Ok(());
1210        }
1211        let signers = live_signers_for(&list, &cid_hex, now);
1212        let _ = publish_invite_registry(transport, community, &session, &signers).await;
1213        Ok(())
1214    })
1215    .await
1216}
1217
1218/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
1219/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
1220/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
1221/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
1222pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1223    let Ok(owner) = community.owner() else { return false };
1224    // Truncation fails toward Public: over-stating it only makes a caller take the
1225    // stronger remedy (privatise + re-found + reissue), while under-stating it
1226    // leaves a live link open behind a ban.
1227    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1228    let cid = community.id();
1229    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1230    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1231        .unwrap_or_default()
1232        .into_iter()
1233        .filter(|(_, f)| f.0 == community.root_epoch.0)
1234        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1235        .collect();
1236    let authority = fold_authority(community, &editions, &floors);
1237    !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1238}
1239
1240/// Page the WHOLE control plane, not the newest window: a registry pushed out of a
1241/// single page reads as retired, and any member can push it out since the plane key
1242/// comes from the community root they hold. `None` = it could NOT be read whole
1243/// (transport failure, same-second wall, pager depth), so a caller must not mistake
1244/// an empty fold for absence.
1245async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1246    let control = control::ControlPlane::of(community);
1247    let mut editions: Vec<ParsedEdition> = Vec::new();
1248    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1249    let mut oldest: Option<u64> = None;
1250    let mut until: Option<u64> = None;
1251    for page in 0..COMPACT_MAX_PAGES {
1252        // Quorum, DECLARED (the until→Full transport floor is gone): these
1253        // control reads tolerate a partial union — their fold semantics are
1254        // fail-safe on gaps (seeded banlists, withheld roster cache).
1255        let query = Query {
1256            kinds: vec![stream::KIND_WRAP],
1257            authors: vec![control.pk_hex()],
1258            until,
1259            limit: Some(FOLLOW_PAGE),
1260            evidence: crate::community::transport::Evidence::Quorum,
1261            ..Default::default()
1262        };
1263        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1264        let mut fresh = 0usize;
1265        for w in &wraps {
1266            if !seen_wraps.insert(w.id) {
1267                continue;
1268            }
1269            fresh += 1;
1270            let at = w.created_at.as_secs();
1271            if oldest.is_none_or(|o| at < o) {
1272                oldest = Some(at);
1273            }
1274            if let Ok((ed, _)) = control.open(w) {
1275                editions.push(ed);
1276            }
1277        }
1278        if fresh == 0 {
1279            if wraps.len() >= FOLLOW_PAGE {
1280                return None; // same-second wall: the plane can't be read whole
1281            }
1282            return Some(editions);
1283        }
1284        until = oldest;
1285        if page + 1 == COMPACT_MAX_PAGES {
1286            return None;
1287        }
1288    }
1289    Some(editions)
1290}
1291
1292/// The live link coordinates PER AUTHORISED CREATOR across every Registry (vsk-8);
1293/// non-empty ⇒ the Community is Public, and the per-creator split is what drives
1294/// "X has N active invite links". Pure over an already-fetched edition set so the
1295/// on-demand probe and the control follow fold it identically.
1296fn live_invite_link_sets(
1297    cid: &crate::community::CommunityId,
1298    owner_hex: &str,
1299    editions: &[ParsedEdition],
1300    authority: &AuthoritySet,
1301    floors: &Floors,
1302) -> Vec<crate::db::community::InviteLinkSetRow> {
1303    use crate::community::roles::Permissions;
1304    use std::collections::BTreeMap;
1305    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1306    for e in editions {
1307        if e.vsk == vsk::INVITE_LINKS {
1308            by_eid.entry(e.entity_id).or_default().push(e);
1309        }
1310    }
1311    let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1312    for (eid, group) in &by_eid {
1313        // Authority BEFORE the fold, matching `apply_control_fold`. `fold_head`
1314        // picks an equal-version winner author-blind (lowest inner id, which an
1315        // author can grind), so folding first would let any member occupy the head
1316        // slot and have the whole registry dropped by the check below — silently
1317        // retiring a live invite link, i.e. flipping the community to Private.
1318        let authed: Vec<&ParsedEdition> = group
1319            .iter()
1320            .copied()
1321            .filter(|p| {
1322                let author = p.author.to_hex();
1323                // The creator must hold CREATE_INVITE, not be banned, AND own this coordinate.
1324                !authority.banned.contains(&author)
1325                    && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1326                    && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1327            })
1328            .collect();
1329        if authed.is_empty() {
1330            continue;
1331        }
1332        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1333        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1334        if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1335            if signers.is_empty() {
1336                continue; // a creator who retired every link is absent, not a zero row
1337            }
1338            sets.push(crate::db::community::InviteLinkSetRow {
1339                creator_hex: authed[hi].author.to_hex(),
1340                locators: signers.iter().map(|p| p.to_hex()).collect(),
1341            });
1342        }
1343    }
1344    sets
1345}
1346
1347/// Flatten per-creator sets into the aggregate the `invite_registry` column holds.
1348fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1349    let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1350    flat.sort();
1351    flat.dedup();
1352    flat
1353}
1354
1355/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
1356/// delivered community_root is genuinely the owner's, persist the community, and
1357/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
1358/// paths. Takes the caller's `std::sync::Arc<crate::db::Session>` (captured BEFORE any network fetch the
1359/// caller did) so the whole operation stays with one account across that I/O.
1360async fn accept_bundle<T: Transport + ?Sized>(
1361    transport: &T,
1362    bundle: &CommunityInvite,
1363    invited_by: Option<PublicKey>,
1364    announce_join: bool,
1365) -> Result<CommunityV2, String> {
1366    crate::db::scoped(async move {
1367        let signer = crate::signer::active_signer()?;
1368        let my_pk = me_pk()?;
1369        let at_ms = now_ms();
1370        // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
1371        if bundle.expired(at_ms) {
1372            return Err("this invite has expired".to_string());
1373        }
1374        // `from_bundle` re-validates bounds + the owner commitment fail-closed.
1375        let community = CommunityV2::from_bundle(bundle, at_ms)?;
1376        // Captured before the save below: a re-accept of a held community must not
1377        // re-announce a membership this account already declared.
1378        let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1379
1380        // Authenticate the delivered community_root before trusting it. The owner
1381        // commitment proves WHO the owner is, but community_root (and channel keys) are
1382        // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
1383        // with an attacker-chosen root and silently partition the joiner onto planes
1384        // only the attacker controls. Requiring the owner's genesis to open under the
1385        // delivered root closes that eclipse; also reconciles channel classification.
1386        // A preview verified the SAME (id, root) moments ago → reuse its fold instead
1387        // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
1388        let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1389            v.session.is_live()
1390                && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1391                && v.community_id == community.id().0
1392                && v.community_root == community.community_root
1393        });
1394        let (community, join_heads, join_banlist, join_pins, join_banlist_content) = match handoff {
1395            Some(v) => {
1396                let mut c = v.folded;
1397                // The preview holds no acquisition time — stamp the JOIN's.
1398                c.created_at_ms = at_ms;
1399                (c, v.heads, v.banned, v.pins, v.banlist_persist)
1400            }
1401            None => {
1402                let vj = verify_owner_root_and_reconcile(transport, community).await?;
1403                (vj.community, vj.heads, vj.banned, vj.pins, vj.banlist_persist)
1404            }
1405        };
1406
1407        // A dissolved community is a grave (CORD-02 §9): refuse to join it.
1408        if is_dissolved(transport, &community).await {
1409            return Err(ERR_DISSOLVED.to_string());
1410        }
1411
1412        // Join-time ban gate (CORD-04 §4, Armada parity): an honest client refuses to join a
1413        // community whose authorized banlist names it — before the Guestbook Join publishes
1414        // and before any local write. Every door funnels through here (direct invite, parked,
1415        // public link, migration), so none of them needs its own exclusion.
1416        if join_banlist.contains(&my_pk.to_hex()) {
1417            return Err("you are banned from this community".to_string());
1418        }
1419
1420        // The account must not have swapped since the guard was captured (which was
1421        // before any fetch the caller / the verify above performed) — else we'd write
1422        // A's join into B.
1423        // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
1424        // community row lands (floors-then-state, so a mid-seed error can't leave saved
1425        // state outrunning its floor); the first post-join follow then can't persist a
1426        // state below what this join already showed.
1427        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1428        for h in &join_heads {
1429            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)?;
1430        }
1431        crate::db::community::save_community_v2(&community)?;
1432        // Archive the joined root at its epoch, so this member reads Public-channel
1433        // history from their join epoch onward across later Refoundings (CORD-03 §3).
1434        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1435        // Same for each granted Private-channel key: the archive is what lets its
1436        // history stay readable after the channel rotates away from this key.
1437        for ch in &community.channels {
1438            if let (true, Some(key)) = (ch.private, ch.key) {
1439                let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1440            }
1441        }
1442        // Persist what the verification walk already folded: the control plane WAS
1443        // read, so making the fresh member wait for the follow worker's queued
1444        // re-walk to see pins or enforce bans is pure lag (it read 20-30s on a busy
1445        // queue). The system "modified the Pins" line still arrives via that first
1446        // follow — a join backfills state, it doesn't announce edits.
1447        if let Some((banned_list, version)) = &join_banlist_content {
1448            let _ = crate::db::community::set_community_banlist(&cid_hex, banned_list, *version as i64);
1449        }
1450        for (channel_hex, content, version, _author, _at) in &join_pins {
1451            if matches!(
1452                crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64),
1453                Ok(true)
1454            ) {
1455                crate::emit_event(
1456                    "community_pins_updated",
1457                    &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
1458                );
1459            }
1460        }
1461
1462        // Announce our Guestbook Join, echoing the invite attribution when present.
1463        // Only an ACTUAL join speaks: a re-accept of a held community, or a
1464        // cross-device key sync (announce_join=false), is not a membership event —
1465        // the account's original Join already stands in the guestbook, and every
1466        // re-publish renders as "<user> has joined" spam for the whole community.
1467        if announce_join && !already_held {
1468            let attribution = invited_by
1469                .map(|p| p.to_hex())
1470                .or_else(|| bundle.creator_npub.clone())
1471                .zip(Some(bundle.label.clone().unwrap_or_default()));
1472            let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1473            let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1474            let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1475            if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1476                let _ = transport.publish(&join_wrap, &community.relays).await;
1477            }
1478        }
1479
1480        // Record the membership across devices (CORD-02 §8). The inline attempt covers the
1481        // happy path; anything else hands off to the durable retry, because an unrecorded
1482        // join is what strands a community behind a stale tombstone.
1483        //
1484        // Only for a REAL join. `announce_join == false` is the list-sync adoption path,
1485        // and those entries were just read FROM this list — republishing it re-records what
1486        // it already holds. A device adopting N entries would otherwise rebuild and publish
1487        // the whole document N times, concurrently, every copy identical, on the boot path
1488        // that is already the slowest thing the app does.
1489        if announce_join {
1490            match republish_community_list(transport, Some(community.id())).await {
1491                Ok(true) => {}
1492                Ok(false) => republish_community_list_durable(Some(*community.id())),
1493                Err(e) => {
1494                    crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1495                    republish_community_list_durable(Some(*community.id()));
1496                }
1497            }
1498        }
1499        Ok(community)
1500    })
1501    .await
1502}
1503
1504/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
1505/// channel classification from the owner's editions. `community_id` commits only
1506/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
1507/// every synced Community List) — so a forged invite can present a real community's
1508/// id/owner/salt with an attacker-chosen root; every plane then derives from that
1509/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
1510/// owner commitment still "verifies". The defense: the owner's genesis metadata
1511/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
1512/// attacker can't forge the owner's seal — so its presence on the control plane
1513/// derived from the delivered root proves that root. On a ROTATED plane (epoch > 0)
1514/// the compaction may have carried an admin-signed metadata head instead (CORD-06
1515/// re-wraps heads with their original signatures), so the anchor there is the
1516/// community-bound metadata head plus any owner-signed edition under the same root.
1517/// Fail-closed: no anchor (forged invite, or relays unreachable) → refuse to join.
1518/// On success, folds the owner's authoritative editions to heal a bundle that
1519/// misclassified a channel.
1520/// Everything the join-time verification walk folded, handed to the accept path so
1521/// what the join VERIFIED is also what it PERSISTS. The walk already paid for the
1522/// control plane read; deferring persistence to the first post-join follow re-pays
1523/// the network cost and leaves the fresh member pins-blind and banlist-blind for
1524/// the whole follow-queue delay.
1525struct VerifiedJoin {
1526    community: CommunityV2,
1527    heads: Vec<FoldedHead>,
1528    /// The join-gate banlist (authorized fold over the any-author edition set).
1529    banned: std::collections::BTreeSet<String>,
1530    /// Pin List heads folded under the FULL join-time authority (curators included,
1531    /// same §5 gate as any later follow): `(channel_hex, content, version, author_npub, created_at)`.
1532    pins: Vec<(String, String, u64, String, u64)>,
1533    /// The authorized banlist head's content + version, for local persistence.
1534    banlist_persist: Option<(Vec<String>, u64)>,
1535}
1536
1537async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1538    transport: &T,
1539    community: CommunityV2,
1540) -> Result<VerifiedJoin, String> {
1541    let owner = community.owner()?;
1542    let control = control::ControlPlane::of(&community);
1543    let control_pk = control.pk_hex();
1544
1545    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
1546    // wraps ONLY to a connection authenticated AS the stream key — Concord's
1547    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
1548    // satisfy the gate and the control plane reads back empty. Register this
1549    // community's stream keys + start the challenge responder so the fetch below
1550    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
1551    super::streamauth::prime(&community);
1552
1553    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
1554    // community_id`) at the root-derived control plane. The genesis eid pins it to
1555    // THIS community, and it lives ONLY under the real root — so a forged root can't
1556    // produce one: an edition's seal carries no community binding, but another
1557    // community's genesis has a different eid, and this community's own genesis is
1558    // unreadable without its real root (which the forger lacks). ("Any owner edition"
1559    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
1560    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
1561    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
1562    // binding the root into community_id (protocol, deferred).
1563    //
1564    // Seed `until` with a FAR-FUTURE constant (NOT now-based), and request
1565    // Evidence::Full EXPLICITLY below: this walk draws an ABSENCE verdict (no
1566    // owner-signed genesis ⇒ reject), which trusts only the completest union —
1567    // an open partial window misses a genesis on a lagging relay (routine over
1568    // Tor). A constant beyond any real created_at clips NOTHING — so neither
1569    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
1570    // now-based bound could clip either). Break on an EMPTY page (a short page is a
1571    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
1572    // that buries the genesis past the walk is the deferred protocol residual.
1573    const PAGE: usize = 500;
1574    const MAX_PAGES: usize = 4;
1575    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
1576    let mut editions: Vec<ParsedEdition> = Vec::new();
1577    let mut all_editions: Vec<ParsedEdition> = Vec::new();
1578    let mut found_genesis = false;
1579    // Rotated planes (CORD-06): compaction re-wraps each entity's CURRENT head with
1580    // its ORIGINAL signature, so if an admin last edited the metadata the plane holds
1581    // no owner-signed vsk-0 at all — the strict genesis anchor is unsatisfiable there.
1582    // Fallback pair for epoch > 0: the community-bound metadata head (any signer) PLUS
1583    // at least one owner-signed edition opened under this root. A non-member forger
1584    // can produce neither; the sibling-community rewrap residual this reopens is the
1585    // same class the spec defers to root-in-id binding.
1586    let mut compacted_metadata = false;
1587    crate::log_debug!(
1588        "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1589        &control_pk[..12], community.root_epoch, community.relays
1590    );
1591    let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1592        found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1593    };
1594    for attempt in 0..2 {
1595        editions.clear();
1596        all_editions.clear();
1597        compacted_metadata = false;
1598        let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1599        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1600        for page_no in 0..MAX_PAGES {
1601            let query = Query {
1602                kinds: vec![stream::KIND_WRAP],
1603                authors: vec![control_pk.clone()],
1604                until,
1605                limit: Some(PAGE),
1606                evidence: crate::community::transport::Evidence::Full,
1607                ..Default::default()
1608            };
1609            let wraps = transport.fetch(&query, &community.relays).await?;
1610            crate::log_trace!(
1611                "[JoinVerify] attempt {} page {}: fetched {} wraps",
1612                attempt, page_no, wraps.len()
1613            );
1614            // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1615            // siblings at a page boundary (and the genesis with them); re-served
1616            // boundary events are free, and no-new-events means exhausted.
1617            let mut oldest = u64::MAX;
1618            let mut fresh = 0usize;
1619            for w in &wraps {
1620                if !seen_wraps.insert(w.id) {
1621                    continue;
1622                }
1623                fresh += 1;
1624                oldest = oldest.min(w.created_at.as_secs());
1625                if let Ok((ed, _)) = control.open(w) {
1626                    crate::log_trace!(
1627                        "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1628                        ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1629                        ed.author == owner, w.created_at.as_secs()
1630                    );
1631                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1632                        if ed.author == owner {
1633                            found_genesis = true;
1634                        } else {
1635                            compacted_metadata = true;
1636                        }
1637                    }
1638                    if ed.author == owner {
1639                        editions.push(ed.clone());
1640                    }
1641                    // Any-author set for the join-time authority fold below: the banlist head
1642                    // may be admin-signed, and its authority chains to the owner regardless.
1643                    all_editions.push(ed);
1644                }
1645            }
1646            crate::log_debug!(
1647                "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1648                attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1649            );
1650            if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1651                break; // authenticated, or the relay is exhausted.
1652            }
1653            until = Some(oldest);
1654        }
1655        if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1656            break;
1657        }
1658        if attempt == 0 {
1659            // AUTH-gating relays: the first walk's REQ triggers the NIP-42 challenge,
1660            // but nostr-sdk's own retry re-auths as the USER key — which doesn't
1661            // satisfy a stream-authors gate — and can land before the responder's
1662            // stream-key auth settles, reading the plane back EMPTY. Replay the
1663            // remembered challenges for every registered stream key, then walk once
1664            // more on the settled connection.
1665            if let Some(client) = crate::state::nostr_client() {
1666                super::streamauth::prime_auth(&client, &community.relays).await;
1667            }
1668        }
1669    }
1670    if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1671        return Err(
1672            "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"
1673                .to_string(),
1674        );
1675    }
1676    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1677    // entity). The heads this fold verified are returned for the caller to SEED as
1678    // the initial floor once the community row is saved — without that, the first
1679    // post-join follow would bootstrap floor-less and could persist a state BELOW
1680    // what this join already verified and showed.
1681    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1682    // above), and the owner is supreme — so owner-only authority suffices. The full
1683    // roster (admins) folds on the first post-join follow_control.
1684    let empty_floors = Floors::new();
1685    let authority = AuthoritySet::owner_only();
1686    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1687    // Join-time banlist: fold authority over the ANY-author edition set (roles/grants
1688    // chain to the genesis-verified owner; the banlist head is honored only if its signer
1689    // held BAN). Returned so the accept path can refuse a banned self BEFORE it publishes
1690    // a Guestbook Join — the gate every join door shares (Armada parity, CORD-04 §4).
1691    let authority_full = fold_authority(&community, &all_editions, &empty_floors);
1692    // Pins under the FULL authority: a curator-authored Pin List is as valid at join
1693    // as on any later follow (same PIN_MESSAGES + citation gate, same owner-chained
1694    // roster). Only the pins are taken from this pass — the DOCUMENT adoption above
1695    // stays owner-only, its stricter envelope unchanged.
1696    let pins = apply_control_fold(&community, &all_editions, &empty_floors, &authority_full).pins_persist;
1697    Ok(VerifiedJoin {
1698        community: fold.updated.unwrap_or(community),
1699        heads: fold.heads,
1700        banned: authority_full.banned,
1701        pins,
1702        banlist_persist: authority_full.banlist_persist,
1703    })
1704}
1705
1706/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1707/// then run the shared accept path. The recipient's consent IS this call. No
1708/// network await precedes the accept, so the guard captured here suffices.
1709pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1710    let signer = crate::signer::active_signer()?;
1711    let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1712    accept_bundle(transport, &bundle, Some(inviter), true).await
1713}
1714
1715/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1716/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1717/// bundle validation, then runs the shared accept path (which re-verifies the owner
1718/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1719/// Join attribution.
1720pub async fn accept_parked_invite<T: Transport + ?Sized>(
1721    transport: &T,
1722    bundle_json: &str,
1723    inviter_hex: Option<&str>,
1724) -> Result<CommunityV2, String> {
1725    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1726    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1727    accept_bundle(transport, &bundle, invited_by, true).await
1728}
1729
1730/// Accept v2 JoinMaterial recovered from a v1→v2 migration dissolution payload (`m`). The
1731/// material IS a bundle's membership subset — rebuild the invite and run the SHARED accept
1732/// path, which re-verifies the owner root over the network and enforces the join-time ban
1733/// gate (a banned-never-cut v1 member who can open `m` is refused here, fail-closed). No
1734/// giftwrap to unwrap: the dissolution already authenticated the owner via its signature.
1735pub async fn accept_migration_material<T: Transport + ?Sized>(
1736    transport: &T,
1737    jm: &super::list::JoinMaterial,
1738) -> Result<CommunityV2, String> {
1739    let bundle = material_to_invite(jm);
1740    accept_bundle(transport, &bundle, None, true).await
1741}
1742
1743/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1744/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1745/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1746/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1747/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1748/// the newest valid Live by `created_at`. Read-only.
1749pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1750    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1751    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1752    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1753    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1754    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1755    let query = Query {
1756        kinds: vec![super::kind::INVITE_BUNDLE],
1757        authors: vec![parsed.link_signer.to_hex()],
1758        ..Default::default()
1759    };
1760    let relays = if parsed.bootstrap_relays.is_empty() {
1761        invite::stock_relays()
1762    } else {
1763        parsed.bootstrap_relays.clone()
1764    };
1765    // One bounded retry: a join fired while the pool is still warming (bootstrap
1766    // relays mid-handshake, routine during boot contention) reads back a transport
1767    // error, not an absent bundle. The pool add already happened on the first try,
1768    // so wait for a socket rather than guessing with a fixed sleep.
1769    let events = match transport.fetch(&query, &relays).await {
1770        Ok(evs) => evs,
1771        Err(_) => {
1772            wait_for_bootstrap_relay(&relays).await;
1773            transport.fetch(&query, &relays).await?
1774        }
1775    };
1776    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1777
1778    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1779    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1780    for event in &events {
1781        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1782            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1783            Ok(invite::BundleState::Live(bundle)) => {
1784                let at = event.created_at.as_secs();
1785                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1786                    newest_live = Some((at, *bundle));
1787                }
1788            }
1789            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1790        }
1791    }
1792    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1793}
1794
1795/// Wait — bounded — for ANY of the targets to report Connected before a retry:
1796/// the fetch's own warm path bounds its connect wait tighter than a cold TLS
1797/// handshake takes under boot contention.
1798async fn wait_for_bootstrap_relay(relays: &[String]) {
1799    let Some(client) = crate::state::nostr_client() else { return };
1800    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1801    loop {
1802        for url in relays {
1803            if let Ok(Some(relay)) = client.relay(url).await {
1804                if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1805                    return;
1806                }
1807            }
1808        }
1809        if tokio::time::Instant::now() >= deadline {
1810            return;
1811        }
1812        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1813    }
1814}
1815
1816/// The most recent owner-root verification a PREVIEW completed, handed to a join
1817/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
1818/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
1819/// a different delivered root never matches. The join's own bundle re-fetch is
1820/// untouched, so the revocation gate always runs live.
1821struct VerifiedPreview {
1822    session: std::sync::Arc<crate::db::Session>,
1823    at: std::time::Instant,
1824    community_id: [u8; 32],
1825    community_root: [u8; 32],
1826    folded: CommunityV2,
1827    heads: Vec<FoldedHead>,
1828    /// The join-time authorized banlist from the SAME verified walk — carried so the
1829    /// handoff path keeps the ban gate (a preview-then-join must not skip it).
1830    banned: std::collections::BTreeSet<String>,
1831    /// Folded pins + banlist content from the same walk, so a preview-handoff join
1832    /// persists them exactly like a direct join.
1833    pins: Vec<(String, String, u64, String, u64)>,
1834    banlist_persist: Option<(Vec<String>, u64)>,
1835}
1836static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1837const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1838
1839/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
1840/// display metadata folded live from the Control Plane (a v2 bundle deliberately
1841/// carries no icon — the fold is the authority). Owner-root verification rides
1842/// the fold, so a forged-root link can't render a convincing preview; on a
1843/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
1844/// — the caller hasn't joined.
1845pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1846    let bundle = fetch_public_bundle(transport, url).await?;
1847    preview_bundle(transport, &bundle).await
1848}
1849
1850/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
1851/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
1852/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
1853/// it again regardless.
1854pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1855    let community = CommunityV2::from_bundle(bundle, 0)?;
1856    match verify_owner_root_and_reconcile(transport, community.clone()).await {
1857        Ok(vj) => {
1858            let folded = vj.community;
1859            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1860                session: crate::db::current_session(),
1861                at: std::time::Instant::now(),
1862                community_id: folded.id().0,
1863                community_root: folded.community_root,
1864                folded: folded.clone(),
1865                heads: vj.heads,
1866                banned: vj.banned,
1867                pins: vj.pins,
1868                banlist_persist: vj.banlist_persist,
1869            });
1870            Ok(folded)
1871        }
1872        Err(_) => Ok(community),
1873    }
1874}
1875
1876/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
1877pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1878    crate::db::scoped(async move {
1879        // Held across the network fetch so the join stays with one account.
1880        let bundle = fetch_public_bundle(transport, url).await?;
1881        accept_bundle(transport, &bundle, None, true).await
1882    })
1883    .await
1884}
1885
1886/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1887pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1888    crate::db::scoped(async move {
1889        let signer = crate::signer::active_signer()?;
1890        let my_pk = me_pk()?;
1891        let at_ms = now_ms();
1892        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1893        let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1894        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1895            let _ = transport.publish(&wrap, &community.relays).await;
1896        }
1897        // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete.
1898        // Best-effort, but never silent: an unpublished tombstone is what lets a stale
1899        // copy of the list rejoin this community on a later boot.
1900        if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
1901            crate::log_net_fail!("[CommunityList] leave tombstone failed ({}) — retrying in the background", e);
1902            tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
1903        }
1904        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1905        Ok(())
1906    })
1907    .await
1908}
1909
1910/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1911/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1912/// `can_kick`), so publishing without authority is inert. A kicked member may
1913/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1914pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1915    crate::db::scoped(async move {
1916        assert_current_root(community)?;
1917        let signer = crate::signer::active_signer()?;
1918        let my_pk = me_pk()?;
1919        // Fast local pre-check; readers re-verify independently.
1920        let authority = fetch_authority(transport, community).await;
1921        let owner_hex = community.owner()?.to_hex();
1922        if !authority.roles.can_act_on_member(
1923            &my_pk.to_hex(),
1924            Some(&owner_hex),
1925            &target.to_hex(),
1926            crate::community::roles::Permissions::KICK,
1927        ) {
1928            return Err("not authorized to kick this member".to_string());
1929        }
1930        // CORD-04 §6 composition: a Kick is Role Removal THEN the directive — strip
1931        // first, so the target's rank is gone before the departure lands. Without it a
1932        // kicked admin leaves the memberlist still holding every management bit, and
1933        // every client keeps honoring their control editions.
1934        //
1935        // SKIPPED (not refused) when the strip isn't ours to make: a revoke needs
1936        // MANAGE_ROLES + strict outrank, and a KICK-only moderator still kicks — the
1937        // target just keeps their rank until an authorized strip lands. Each layer
1938        // validates on its own rule, so a missing one is a weaker removal, never a
1939        // broken one. A strip we DO attempt and lose is a hard error: proceeding would
1940        // publish a directive we know leaves rank behind.
1941        let target_hex = target.to_hex();
1942        let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1943        let may_strip = authority.roles.can_act_on_member(
1944            &my_pk.to_hex(),
1945            Some(&owner_hex),
1946            &target_hex,
1947            crate::community::roles::Permissions::MANAGE_ROLES,
1948        );
1949        if holds_roles && may_strip {
1950            grant_roles(transport, community, target, Vec::new())
1951                .await
1952                .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1953        }
1954        let at_ms = now_ms();
1955        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1956        // A Kick is an authority action, so it cites its Grant like any other
1957        // (CORD-02 §5 / CORD-04 §5).
1958        let citation = required_authority_citation(community, &my_pk)?;
1959        let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1960        let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1961            .map_err(|e| e.to_string())?;
1962        transport.publish(&wrap, &community.relays).await?;
1963        Ok(())
1964    })
1965    .await
1966}
1967
1968/// A community's folded, delegation-authorized authority — the on-demand read
1969/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1970/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1971/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1972/// entity (floored locally but no head folded) before replacing it blind.
1973pub struct AuthorityView {
1974    pub roles: crate::community::roles::CommunityRoles,
1975    pub banned: std::collections::BTreeSet<String>,
1976    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1977    pub gapped: bool,
1978    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1979    pub floored: std::collections::BTreeSet<String>,
1980    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1981    pub head_entities: std::collections::BTreeSet<String>,
1982    /// Ban history (npub hex → secs), outliving the ban so an un-ban raises no phantom.
1983    pub banned_at: std::collections::BTreeMap<String, u64>,
1984}
1985
1986/// Fetch + fold the community's current authority (CORD-04), paging older like
1987/// `follow_control` while the fold is gapped so a busy control plane can't push
1988/// the roster off the newest window. A fetch failure degrades fail-safe:
1989/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1990/// an outage, and a ban never lifts on withheld data.
1991pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1992    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1993    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1994        .unwrap_or_default()
1995        .into_iter()
1996        .filter(|(_, f)| f.0 == community.root_epoch.0)
1997        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1998        .collect();
1999    let control = control::ControlPlane::of(community);
2000
2001    let mut editions: Vec<ParsedEdition> = Vec::new();
2002    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2003    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2004    let mut oldest: Option<u64> = None;
2005    let mut until: Option<u64> = None;
2006    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
2007    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
2008    // error returns the stored bans (fail-safe), never an empty banlist that would
2009    // silently un-ban on withheld data.
2010    let mut a = fold_authority(community, &[], &floors);
2011    for _ in 0..FOLLOW_MAX_PAGES {
2012        // Quorum, DECLARED (the until→Full transport floor is gone): these
2013        // control reads tolerate a partial union — their fold semantics are
2014        // fail-safe on gaps (seeded banlists, withheld roster cache).
2015        let query = Query {
2016            kinds: vec![stream::KIND_WRAP],
2017            authors: vec![control.pk_hex()],
2018            until,
2019            limit: Some(FOLLOW_PAGE),
2020            evidence: crate::community::transport::Evidence::Quorum,
2021            ..Default::default()
2022        };
2023        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
2024        let mut fresh = 0usize;
2025        for w in &wraps {
2026            if !seen_wraps.insert(w.id) {
2027                continue;
2028            }
2029            fresh += 1;
2030            let at = w.created_at.as_secs();
2031            if oldest.is_none_or(|o| at < o) {
2032                oldest = Some(at);
2033            }
2034            if let Ok((ed, _)) = control.open(w) {
2035                if seen.insert(ed.inner_id) {
2036                    editions.push(ed);
2037                }
2038            }
2039        }
2040        a = fold_authority(community, &editions, &floors);
2041        if !a.gapped || fresh == 0 {
2042            break;
2043        }
2044        until = oldest;
2045    }
2046    AuthorityView {
2047        roles: a.roles,
2048        banned: a.banned,
2049        gapped: a.gapped,
2050        floored: floors.keys().cloned().collect(),
2051        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
2052        banned_at: a.banned_at,
2053    }
2054}
2055
2056/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
2057/// falls below `since_secs` (everything older is already held) or the plane is
2058/// exhausted. Returns the parsed events at/after the window plus the newest wrap
2059/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
2060///
2061/// PAGE bound rationale: a single 500-window silently drops a member whose Join
2062/// aged out (organic growth, or an insider flooding throwaway Joins), and
2063/// `refound_community` consumes the fold as its rekey recipient set — a dropped
2064/// member is SEVERED. Beyond this depth a community needs sharding (documented);
2065/// the granted-member union in [`fold_members`] is the consensus-complete
2066/// backstop regardless of Guestbook depth.
2067async fn fetch_guestbook_events<T: Transport + ?Sized>(
2068    transport: &T,
2069    community: &CommunityV2,
2070    since_secs: u64,
2071) -> Result<(Vec<guestbook::GuestbookEvent>, u64, bool), String> {
2072    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2073    const GB_PAGE: usize = 500;
2074    const GB_MAX_PAGES: usize = 12;
2075    let mut events = Vec::new();
2076    let mut newest: u64 = since_secs;
2077    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2078    let mut until: Option<u64> = None;
2079    let mut oldest: Option<u64> = None;
2080    // Did the walk run out of PLANE, or out of PAGES? Only the former means the
2081    // caller holds everything down to `since_secs` — advancing a cursor on the
2082    // latter skips whatever the walk never reached, permanently.
2083    let mut reached_end = false;
2084    for _ in 0..GB_MAX_PAGES {
2085        // Full: this set becomes the refound's recipient list — a member's
2086        // Join visible only on a minority relay must not be severed.
2087        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() };
2088        let wraps = transport.fetch(&query, &community.relays).await?;
2089        let mut fresh = 0usize;
2090        for wrap in &wraps {
2091            if !seen.insert(wrap.id) {
2092                continue;
2093            }
2094            fresh += 1;
2095            let at = wrap.created_at.as_secs();
2096            if oldest.is_none_or(|o| at < o) {
2097                oldest = Some(at);
2098            }
2099            if at > newest {
2100                newest = at;
2101            }
2102            // Older than the cursor window — already held; skip the decrypt.
2103            if at < since_secs {
2104                continue;
2105            }
2106            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
2107                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
2108                    events.push(ev);
2109                }
2110            }
2111        }
2112        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
2113            reached_end = true;
2114            break;
2115        }
2116        match oldest {
2117            Some(o) if o > 0 => until = Some(o),
2118            // Nothing older to ask for.
2119            _ => {
2120                reached_end = true;
2121                break;
2122            }
2123        }
2124    }
2125    Ok((events, newest, reached_end))
2126}
2127
2128/// The shared membership fold: coalesce Guestbook events under the community's
2129/// authority (owner-supreme kicks, refounder snapshots), union observed authors
2130/// plus every roster grantee, subtract the banlist, and pin the proven owner.
2131/// One implementation, so the live and stored reads can't drift.
2132fn fold_members(
2133    community: &CommunityV2,
2134    events: &[guestbook::GuestbookEvent],
2135    mut observed: std::collections::BTreeMap<PublicKey, u64>,
2136    roles: &crate::community::roles::CommunityRoles,
2137    banlist: &std::collections::BTreeSet<PublicKey>,
2138    banned_at: &std::collections::BTreeMap<PublicKey, u64>,
2139) -> Result<Vec<PublicKey>, String> {
2140    let owner = community.owner()?;
2141    let owner_hex = owner.to_hex();
2142    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2143
2144    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
2145    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
2146    // even if their Join aged out of the Guestbook entirely and they never posted.
2147    // This is what keeps a Refounding from severing a lurking admin. `observed`
2148    // carries them at ts 0 (presence, not recency); the banlist subtraction below
2149    // still removes a banned grantee whose grant wasn't yet stripped.
2150    for g in &roles.grants {
2151        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
2152            observed.entry(pk).or_insert(0);
2153        }
2154    }
2155
2156    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
2157    // new epoch's Guestbook with a 3312 snapshot of the survivors. Only the OWNER's snapshot is
2158    // honored here, so a silent survivor stays in the memberlist across an owner refound
2159    // without re-posting. A genesis community (root_epoch 0) has no refounder, hence no
2160    // snapshot power. KNOWN GAP (do not "fix" unilaterally — CORD-04/06 + Armada): the refound
2161    // send/receive gates authorize any BAN-holder to refound, but their snapshot is NOT honored
2162    // here, so a non-owner admin's refound drops silent survivors (incl. migration roster seeds)
2163    // until they re-post. Binding the minting rotator into snapshot authority is a spec change.
2164    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2165    // Kick authority (CORD-04 §5/§6): the signer must cite a Grant we've synced AND
2166    // hold KICK AND strictly outrank the target (the owner is supreme; equal cannot
2167    // kick equal).
2168    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2169        let actor_hex = actor.to_hex();
2170        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2171            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2172    };
2173    let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
2174    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
2175    // The owner is a member by definition, independent of any fetched Join.
2176    if !banlist.contains(&owner) {
2177        members.insert(owner);
2178    }
2179    Ok(members.into_iter().collect())
2180}
2181
2182/// Did the AUTHORIZED Guestbook coalesce rule `member` KICKED, per the stored plane?
2183///
2184/// This is the only sound basis for acting on a kick against ourselves. The
2185/// memberlist is the wrong question: it also folds the banlist, the ban marks and
2186/// observed authors, so a member whose Guestbook hasn't caught up yet — a REJOIN,
2187/// where the store starts empty while the control fold has already re-derived their
2188/// old ban mark — is absent from it while being perfectly joined. Coalescing asks
2189/// only "what is the latest authorized entry for this npub", so a fresh Join
2190/// supersedes an old Kick and an empty store yields no verdict at all.
2191pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2192    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2193    let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2194        return false;
2195    };
2196    let Ok(owner) = community.owner() else { return false };
2197    let owner_hex = owner.to_hex();
2198    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2199    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2200    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2201        let actor_hex = actor.to_hex();
2202        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2203            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2204    };
2205    matches!(
2206        guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2207        Some(st) if st.verdict == guestbook::Verdict::Kicked
2208    )
2209}
2210
2211/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
2212/// from zero). The fetch straddles the network, so the session re-checks before
2213/// the store writes. Returns the events that were NEW to the store — the caller
2214/// surfaces them (presence lines) and refreshes on non-empty.
2215pub async fn sync_guestbook<T: Transport + ?Sized>(
2216    transport: &T,
2217    community: &CommunityV2,
2218) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2219    crate::db::scoped(async move {
2220        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2221        let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2222        // First sync of the session walks the WHOLE plane; later ones ride the cursor.
2223        // The cursor alone can only ever move forward, so any history a walk missed —
2224        // a page cap, an open breaker, stream-auth not yet landed, one relay of several
2225        // answering — is skipped for good, and two devices settle on different member
2226        // counts that never converge. Re-walking each session heals that, and it also
2227        // covers what no persisted flag can: a walk ends on a short page when the relays
2228        // that ANSWERED had no more, which is not the same as the plane being exhausted.
2229        let full = !guestbook_walked_this_session(&cid_hex);
2230        // Overlap one second so a same-second boundary event can't slip the cursor;
2231        // the rumor-id merge below dedups the re-fetched edge.
2232        let since = if full { 0 } else { cursor.saturating_sub(1) };
2233        let (fresh, newest, reached_end) = fetch_guestbook_events(transport, community, since).await?;
2234        let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2235        let mut added = Vec::new();
2236        for ev in fresh {
2237            if !known.contains(&ev.rumor_id) {
2238                events.push(ev.clone());
2239                added.push(ev);
2240            }
2241        }
2242        // Advance ONLY on a walk that ran out of plane. `newest` counts every wrap seen,
2243        // including ones skipped or that failed to open, so moving it after a truncated
2244        // walk is what buries the events that walk never reached.
2245        let advanced = if reached_end { newest.max(cursor) } else { cursor };
2246        if !added.is_empty() || advanced > cursor {
2247            crate::db::community::set_guestbook(&cid_hex, &events, advanced)?;
2248        }
2249        if full && reached_end {
2250            mark_guestbook_walked(&cid_hex);
2251        }
2252        Ok(added)
2253    })
2254    .await
2255}
2256
2257/// Communities whose Guestbook plane has been walked end-to-end this session.
2258/// Lives on the Session, so an account swap drops it and the next account walks
2259/// its own rather than inheriting this one's coverage.
2260struct GuestbookWalked;
2261
2262fn guestbook_walked_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
2263    crate::db::current_session().scoped::<GuestbookWalked, _>()
2264}
2265
2266fn guestbook_walked_this_session(community_id: &str) -> bool {
2267    guestbook_walked_set().lock().is_ok_and(|s| s.contains(community_id))
2268}
2269
2270fn mark_guestbook_walked(community_id: &str) {
2271    if let Ok(mut s) = guestbook_walked_set().lock() {
2272        s.insert(community_id.to_string());
2273    }
2274}
2275
2276/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
2277/// Returns whether it was new.
2278pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2279    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2280    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2281    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2282        return Ok(false);
2283    }
2284    events.push(ev);
2285    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2286    Ok(true)
2287}
2288
2289/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
2290/// observed authors (the synced events DB), plus roster grantees, minus the
2291/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
2292/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
2293/// remains the authoritative walk — a refounding's rekey recipient set must
2294/// never trust a possibly-stale store.
2295pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2296    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2297    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2298    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2299    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2300        if let Ok(pk) = PublicKey::parse(&npub) {
2301            observed.insert(pk, last_active_secs.saturating_mul(1000));
2302        }
2303    }
2304    let roles = crate::db::community::get_community_roles(&cid_hex)?;
2305    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2306        .unwrap_or_default()
2307        .iter()
2308        .filter_map(|h| PublicKey::from_hex(h).ok())
2309        .collect();
2310    // Ban history outlives the banlist itself — see [`fold_members`]. Read from the store,
2311    // since this path never folds editions.
2312    let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2313        .unwrap_or_default()
2314        .into_iter()
2315        .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2316        .collect();
2317    fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2318}
2319
2320/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
2321/// ALWAYS a member (derived from the self-certifying community_id — no network,
2322/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
2323/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
2324/// member whose Join was lost still counts.
2325pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2326    let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2327    // Observed authors: fold each held channel's recent authorship (real author +
2328    // newest ms), so a member who posted but whose Join was lost is still counted.
2329    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2330    for ch in &community.channels {
2331        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2332            for f in &page {
2333                let e = observed.entry(f.event.opened().author).or_insert(0);
2334                *e = (*e).max(f.event.opened().at_ms);
2335            }
2336        }
2337    }
2338
2339    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
2340    // ban subtraction. A control fetch failure degrades to owner-only authority + no
2341    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
2342    // and a missed ban only fails to HIDE, never to wrongly admit authority).
2343    let authority = fetch_authority(transport, community).await;
2344    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
2345    let banlist: std::collections::BTreeSet<PublicKey> =
2346        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2347    // Union the live fold's ban history with the stored marks: the fetch only reaches the
2348    // editions still in its window, and a ban that aged out is exactly the one whose
2349    // pre-ban Join would phantom.
2350    let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2351        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2352    )
2353    .unwrap_or_default()
2354    .into_iter()
2355    .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2356    .collect();
2357    for (h, at) in &authority.banned_at {
2358        if let Ok(pk) = PublicKey::from_hex(h) {
2359            let slot = banned_at.entry(pk).or_insert(0);
2360            *slot = (*slot).max(*at);
2361        }
2362    }
2363    fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2364}
2365
2366// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
2367
2368/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
2369/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
2370/// past or present member resolves the same grave and a Refounding can never strand
2371/// it). The tombstone's presence IS the state; only the owner's seal counts.
2372/// Irreversible — on success the local hold is sealed read-only AND the owner's
2373/// own membership is tombstoned out of the §8 List: dissolving is also leaving,
2374/// so the owner's other devices tear the community down on their next list sync
2375/// instead of keeping a sealed husk they believe is still held.
2376pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2377    crate::db::scoped(async move {
2378        let signer = crate::signer::active_signer()?;
2379        let my_pk = me_pk()?;
2380        if community.owner()? != my_pk {
2381            return Err("only the owner can dissolve a community".to_string());
2382        }
2383        let at_ms = now_ms();
2384        let at = at_ms / 1000;
2385        let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2386        let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2387        // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
2388        transport.publish_durable(&wrap, &community.relays).await?;
2389        crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2390        // Dissolving IS leaving (same discipline as `leave_community`): retire the
2391        // membership from the cross-device List in the same breath as the grave.
2392        // Without this the tombstone never reaches kind 33302 — the teardown above
2393        // this call only ever spoke the retired v1 mirror — so every sibling device
2394        // and every §8 client kept the entry LIVE, folded the grave, and showed a
2395        // dissolved husk that had to be removed by hand. Best-effort with a durable
2396        // retry, never failing the dissolve: the grave is already published, and an
2397        // unrecorded leave only costs the husk lingering until the retry lands.
2398        if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2399            crate::log_net_fail!("[CommunityList] dissolve tombstone failed ({}) — retrying in the background", e);
2400            tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2401        }
2402        Ok(())
2403    })
2404    .await
2405}
2406
2407/// Whether a valid owner-signed dissolution tombstone exists for this community on
2408/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
2409/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
2410/// owner-verified tombstone found is authoritative.
2411pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2412    let group = super::derive::dissolved_group_key(community.id());
2413    let query = Query {
2414        kinds: vec![stream::KIND_WRAP],
2415        authors: vec![group.pk_hex()],
2416        limit: Some(20),
2417        ..Default::default()
2418    };
2419    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2420        return false;
2421    };
2422    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2423}
2424
2425// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
2426
2427/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
2428/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
2429/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
2430/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
2431/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
2432/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
2433///
2434/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
2435/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
2436/// transient miss never strands a published rekey with a half-anchored plane.
2437pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2438    crate::db::scoped(async move {
2439        let cid = community.id();
2440        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2441        // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
2442        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2443            return Err("this community has been dissolved; it cannot be re-founded".to_string());
2444        }
2445        let signer = crate::signer::active_signer()?;
2446        let my_pk = me_pk()?;
2447        // Serialize with the follow worker for the whole rotation: the commit tail
2448        // whole-row-saves, and an unserialized concurrent follow could otherwise be
2449        // rolled back (or adopt a half-published sibling of this very rotation).
2450        let lock = super::realtime::follow_lock(cid);
2451        let _guard = lock.lock().await;
2452        // Reload the FRESHEST base state: a stale caller struct would address the rotation
2453        // under a superseded root (a base fork with no heal). The community_id is
2454        // self-certifying + stable, so re-loading by it is safe.
2455        let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2456        let community = &fresh;
2457        let owner = community.owner()?;
2458
2459        // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
2460        // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
2461        // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
2462        // and the banlist authority fold: any admin holding BAN may re-found, checked
2463        // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
2464        // only the owner able to re-found.
2465        {
2466            let owner_hex = owner.to_hex();
2467            let me_hex = my_pk.to_hex();
2468            // Persisted (last-folded) roster — the receive side is authoritative, so
2469            // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
2470            // collapses to owner-only, which can only OVER-restrict a fresh admin whose
2471            // grant hasn't folded into their own DB (the caller's ban flow folds control
2472            // first). It can never grant authority no one has.
2473            let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2474            let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2475            let authorized = my_pk == owner
2476                || (!banned.contains(&me_hex)
2477                    && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2478                    && removed.iter().all(|t| {
2479                        roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2480                    }));
2481            if !authorized {
2482                return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2483            }
2484        }
2485
2486        // Fold the current roster: the opened editions are reused for the compaction (their
2487        // seals re-wrap under the new epoch), and the roster gates which admin-authored
2488        // heads carry forward.
2489        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2490            .into_iter()
2491            .filter(|(_, f)| f.0 == community.root_epoch.0)
2492            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2493            .collect();
2494        let current_control = control::ControlPlane::of(community);
2495        // Page the ENTIRE control plane, not just the newest window: the compaction MUST
2496        // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
2497        // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
2498        // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
2499        // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
2500        // at the new epoch a fresh joiner bootstraps.
2501        let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2502        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2503        let mut oldest: Option<u64> = None;
2504        let mut until: Option<u64> = None;
2505        // Read to EXHAUSTION, not to coverage: an entity with no floor yet (a
2506        // first-ever Banlist published while we were away) is invisible to a
2507        // coverage test, so stopping there could compact it away.
2508        let mut truncated = false;
2509        for page in 0..COMPACT_MAX_PAGES {
2510            // Full: compaction re-wraps the head set it can SEE — a control
2511            // edition (a ban head) reachable only on a minority relay must not be
2512            // compacted away by a partial union.
2513            let query = Query {
2514                kinds: vec![stream::KIND_WRAP],
2515                authors: vec![current_control.pk_hex()],
2516                until,
2517                limit: Some(FOLLOW_PAGE),
2518                evidence: crate::community::transport::Evidence::Full,
2519                ..Default::default()
2520            };
2521            let wraps = transport.fetch(&query, &community.relays).await?;
2522            let mut fresh = 0usize;
2523            for w in &wraps {
2524                if !seen_wraps.insert(w.id) {
2525                    continue;
2526                }
2527                fresh += 1;
2528                let at = w.created_at.as_secs();
2529                if oldest.is_none_or(|o| at < o) {
2530                    oldest = Some(at);
2531                }
2532                if let Ok(parsed) = current_control.open(w) {
2533                    opened.push(parsed);
2534                }
2535            }
2536            if fresh == 0 {
2537                // `until` is inclusive: a FULL page with nothing new is a same-second
2538                // wall no cursor steps past, so older editions stay unreachable. A
2539                // short page is simply the end of the plane.
2540                truncated = wraps.len() >= FOLLOW_PAGE;
2541                break;
2542            }
2543            until = oldest;
2544            if page + 1 == COMPACT_MAX_PAGES {
2545                truncated = true;
2546            }
2547        }
2548        if truncated {
2549            return Err(
2550                "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(),
2551            );
2552        }
2553
2554        let prev_epoch = community.root_epoch;
2555        let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2556        let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2557        // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
2558        // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
2559        // it can't double-mint two roots a receiver's correlation dedup would collapse into
2560        // a permanent fork (CORD-06 §3 idempotency). The compaction fetch above straddled
2561        let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2562        // Every compliant base rotation mints the split beside the root (CORD-06
2563        // §3) — a legacy community upgrades as a side effect of this Refounding.
2564        // Reserved under the same retry-idempotency: both attempts must deliver ONE
2565        // pair, or staff adopt whichever secret rode the chunk with their locator.
2566        let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
2567        let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
2568        // The new epoch's Control plane is SPLIT: wraps sign with the fresh
2569        // control_root-derived signer and encrypt under the new community_root-
2570        // derived read key. Editions are never ALSO mirrored to the legacy-derived
2571        // address — that would re-open exactly the member-writable surface the
2572        // split closes (CORD-06 §3).
2573        let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
2574        let at = now_ms();
2575        let at_secs = at / 1000;
2576
2577        // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
2578        // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
2579        // including entities the metadata/roster folds don't touch (the invite Registry
2580        // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
2581        // can't be folded (buried past the pager / withheld) ABORTS before any publish.
2582        use std::collections::BTreeMap;
2583        let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2584        for (i, (e, _)) in opened.iter().enumerate() {
2585            by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2586        }
2587        let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2588        for (floor_key, floor) in &floors {
2589            // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
2590            // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
2591            // so matching it is authority-correct across EVERY entity type. `fold_head`'s
2592            // version-chain TIP is author-BLIND: a member can seal a forged higher-version
2593            // edition chaining onto the floor, which the tip would carry and honest folders
2594            // then DROP as unauthorized — silently suppressing that role/grant/banlist across
2595            // the refounding. Abort if the committed head isn't served (fail-closed).
2596            let head_idx = by_eid
2597                .get(floor_key)
2598                .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2599            let Some(head_idx) = head_idx else {
2600                return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2601            };
2602            let (head_ed, head_os) = &opened[head_idx];
2603            let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2604            let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2605            carried.push((h, rewrapped));
2606        }
2607
2608        // Recipients: the current members minus `removed`, plus me (multi-device).
2609        let members = memberlist(transport, community).await?;
2610        let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2611        let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2612        if !recipients.iter().any(|p| *p == my_pk) {
2613            recipients.push(my_pk);
2614        }
2615
2616        // The freshest roster reachable, for two per-recipient decisions below:
2617        // channel entitlement AND staff-ness (who receives the 136-byte blob).
2618        // Entitlement must come from a CURRENT roster, not the last-folded cache:
2619        // the base recipients above are a fresh network fold, and mixing the two
2620        // strands anyone granted since this client last folded — they keep a dead
2621        // key and the new epoch's rekey plane carries no blob for them. Fetched,
2622        // then merged over the cache so a role we published ourselves survives too.
2623        let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2624        {
2625            let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2626            for r in cached.roles {
2627                if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2628                    roster_for_channels.roles.push(r);
2629                }
2630            }
2631            for g in cached.grants {
2632                if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2633                    roster_for_channels.grants.push(g);
2634                }
2635            }
2636        }
2637        let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2638
2639        // Base rekey blobs, sealed under the PRIOR root: every member's carries the
2640        // new root + control_pk (104 bytes); a STAFF recipient's (CORD-04 §3)
2641        // appends the control_root itself (136) — the rotator is staff by
2642        // construction (a Refounding takes BAN). A stale-roster overshoot hands a
2643        // just-demoted member only flooding power until the next rotation, the
2644        // spec's accepted erosion; an undershoot self-heals via the Grant's
2645        // control_wrap re-delivery.
2646        let mut base_blobs = Vec::new();
2647        for r in &recipients {
2648            let staff = *r == my_pk || roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref());
2649            base_blobs.push(
2650                super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, new_epoch, &new_root, &new_control_pk.to_bytes(), staff.then_some(&new_control_root))
2651                    .await
2652                    .map_err(|e| e.to_string())?,
2653            );
2654        }
2655        let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2656        let base_chunks =
2657            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())
2658                .await
2659                .map_err(|e| e.to_string())?;
2660
2661        // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
2662        // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
2663        //
2664        // Each private channel goes only to ITS entitled set, never the base recipient
2665        // list: a Refounding that re-broadcast every private key to every member would
2666        // undo the access lists on every rotation (CORD-03).
2667        let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2668        let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2669        for ch in &community.channels {
2670            let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2671            let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2672            let entitled: Vec<PublicKey> = recipients
2673                .iter()
2674                .copied()
2675                .filter(|r| {
2676                    *r == my_pk
2677                        || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2678                })
2679                .collect();
2680            let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2681            // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
2682            // retry-idempotency as the base root. The base-rekey signing above is a bunker
2683            // round-trip; re-check before this per-channel DB write straddles it.
2684            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)?;
2685            let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2686            let mut ch_blobs = Vec::new();
2687            for r in &entitled {
2688                ch_blobs.push(
2689                    super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2690                        .await
2691                        .map_err(|e| e.to_string())?,
2692                );
2693            }
2694            let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2695            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())
2696                .await
2697                .map_err(|e| e.to_string())?;
2698            channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2699            channel_chunk_sets.push(ch_chunks);
2700        }
2701
2702        // COMMIT (durable publishes only — all fetching is done). Base rekey first
2703        // (delivers the new root), then channel rekeys, then the compacted control.
2704        for c in &base_chunks {
2705            transport.publish_durable(c, &community.relays).await?;
2706        }
2707        for set in &channel_chunk_sets {
2708            for c in set {
2709                transport.publish_durable(c, &community.relays).await?;
2710            }
2711        }
2712        for (_, wrap) in &carried {
2713            transport.publish_durable(wrap, &community.relays).await?;
2714        }
2715        // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
2716        // it; an omitted member heals by publishing their own Join).
2717        let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2718        let snap_id = crate::community::random_32();
2719        for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2720            if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2721                let _ = transport.publish(&wrap, &community.relays).await;
2722            }
2723        }
2724
2725        // COMMIT locally, only now that the new root + compacted plane are on relays.
2726        if crate::db::community::community_protocol(cid)?.is_none() {
2727            return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
2728        }
2729        // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
2730        // never leave the base root advanced while the channel keys lag (which would
2731        // re-derive the channel rekey address under the wrong root and orphan them).
2732        let mut updated = community.clone();
2733        updated.community_root = new_root;
2734        updated.root_epoch = new_epoch;
2735        updated.control_pk = Some(new_control_pk);
2736        updated.control_root = Some(new_control_root);
2737        for (id, key, ep) in &channel_updates {
2738            if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2739                c.key = Some(*key);
2740                c.epoch = *ep;
2741            }
2742        }
2743        crate::db::community::save_community_v2(&updated)?;
2744        // Archive the new epoch key + confirm the monotonic base head (the root was already
2745        // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
2746        // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
2747        // compacted control on the next follow, so they self-heal.
2748        crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2749        for (h, _) in &carried {
2750            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2751        }
2752        // Re-subscribe NOW: the rotation changed every plane author, and the live sub
2753        // still carries the OLD epoch's set. Members adopt via the follow worker
2754        // (which refreshes); the REFOUNDER has no such path — without this, the very
2755        // client that performed the ban goes deaf to the new epoch (a rejoin lands on
2756        // the relays and never arrives live).
2757        if let Some(client) = crate::state::nostr_client() {
2758            super::realtime::refresh_subscription(&client).await;
2759        }
2760        // Refresh any live public links so their bundles carry the NEW root behind the
2761        // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
2762        // so retry a transient failure — a stranded link lands a new joiner on the dead
2763        // pre-refound epoch, and there's no other trigger to heal it before the next
2764        // refounding. A persistent failure is logged (refound already succeeded).
2765        for attempt in 0..3u8 {
2766            match refresh_public_links(transport, &updated).await {
2767                Ok(()) => break,
2768                Err(e) if attempt == 2 => {
2769                    crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2770                }
2771                Err(_) => continue,
2772            }
2773        }
2774        Ok(updated)
2775    })
2776    .await
2777}
2778
2779/// BIRTH refound (§migration Phase 1.4): roll a freshly-minted migration twin from epoch 0
2780/// to epoch 1 so it can carry an owner-signed Guestbook SNAPSHOT of the full v1 memberlist —
2781/// genesis (epoch 0) has no snapshot authority (`fold_members` gates on `root_epoch > 0`), so
2782/// this is the ONLY way to seed a roster every honest client folds. UNLIKE [`refound_community`]
2783/// the two sets are DECOUPLED:
2784///
2785/// - **Rekey recipients = {owner} ONLY.** Members do NOT get the epoch-1 root via birth blobs
2786///   — they get it from the migration carrier's `m` (sealed AFTER this returns). Keeping the
2787///   set at {owner} also keeps the rotation to a single chunk (blobs shard at 80 per event,
2788///   so a big memberlist would cost a stack of events delivering roots nobody uses).
2789/// - **Snapshot members = the EXPLICIT full v1 list** (`snapshot_members`, display/roster only,
2790///   no keys). Chunked at SNAPSHOT_CHUNK (400)/rumor, no cap — a 10k-member community seeds fine.
2791///
2792/// The SAFEST refound possible: the owner authored 100% of the control plane seconds ago and
2793/// holds every edition locally, so the fold-all-or-abort discipline is trivially met (a flaky
2794/// relay just fires the abort → the wizard retries). Returns the epoch-1 community.
2795pub async fn refound_at_birth<T: Transport + ?Sized>(
2796    transport: &T,
2797    community: &CommunityV2,
2798    snapshot_members: &[PublicKey],
2799) -> Result<CommunityV2, String> {
2800    crate::db::scoped(async move {
2801        let cid = community.id();
2802        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2803        // Death wins every race: a dissolved community never re-founds (CORD-02 §9, parity with
2804        // refound_community). A migration twin should never be dissolved mid-build, but fail-closed.
2805        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2806            return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2807        }
2808        let signer = crate::signer::active_signer()?;
2809        let my_pk = me_pk()?;
2810        if my_pk != community.owner()? {
2811            return Err("only the owner can birth-refound the migration twin".to_string());
2812        }
2813        let lock = super::realtime::follow_lock(cid);
2814        let _guard = lock.lock().await;
2815        let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2816        // RESUME IDEMPOTENCE: if the refound already committed locally (epoch 1) but crashed
2817        // before its ledger write, the wizard re-calls this. The epoch advance + compaction only
2818        // commit AFTER the snapshot published durably + verified back (below), so an epoch-1 twin
2819        // means the snapshot already landed and is readable — return it. A twin past epoch 1 is
2820        // unexpected (nothing else rotates a mid-migration twin).
2821        if community.root_epoch.0 == 1 {
2822            return Ok(community);
2823        }
2824        if community.root_epoch.0 != 0 {
2825            return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2826        }
2827        let community = &community;
2828
2829        // Compact the epoch-0 control plane onto epoch 1: re-wrap the committed head of every
2830        // floored entity VERBATIM (inner owner/admin signatures survive). The owner holds every
2831        // edition locally (authored seconds ago), so this fold-all-or-abort is trivially met.
2832        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2833            .into_iter()
2834            .filter(|(_, f)| f.0 == community.root_epoch.0)
2835            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2836            .collect();
2837        let current_control = control::ControlPlane::of(community);
2838        let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2839        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2840        let mut oldest: Option<u64> = None;
2841        let mut until: Option<u64> = None;
2842        // Exhaustion, not coverage — see the sibling read in `refound_community`.
2843        let mut truncated = false;
2844        for page in 0..COMPACT_MAX_PAGES {
2845            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() };
2846            let wraps = transport.fetch(&query, &community.relays).await?;
2847            let mut fresh = 0usize;
2848            for w in &wraps {
2849                if !seen_wraps.insert(w.id) { continue; }
2850                fresh += 1;
2851                let at = w.created_at.as_secs();
2852                if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2853                if let Ok(parsed) = current_control.open(w) {
2854                    opened.push(parsed);
2855                }
2856            }
2857            if fresh == 0 {
2858                truncated = wraps.len() >= FOLLOW_PAGE;
2859                break;
2860            }
2861            until = oldest;
2862            if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2863        }
2864        if truncated {
2865            return Err(
2866                "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(),
2867            );
2868        }
2869
2870        let prev_epoch = community.root_epoch; // 0
2871        let new_epoch = Epoch(1);
2872        let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2873        let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2874        // The split's pair, minted beside the root exactly as in `refound_community`.
2875        let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
2876        let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
2877        let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
2878        let at = now_ms();
2879        let at_secs = at / 1000;
2880
2881        use std::collections::BTreeMap;
2882        let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2883        for (i, (e, _)) in opened.iter().enumerate() {
2884            by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2885        }
2886        let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2887        for (floor_key, floor) in &floors {
2888            let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2889            let Some(head_idx) = head_idx else {
2890                return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2891            };
2892            let (head_ed, head_os) = &opened[head_idx];
2893            let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2894            let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2895            carried.push((h, rewrapped));
2896        }
2897
2898        // Base rekey: the epoch-1 root to the OWNER ONLY (members key up via the
2899        // carrier's `m`). The owner is staff by definition — the 136-byte form.
2900        let base_blobs = vec![
2901            super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), &my_pk, new_epoch, &new_root, &new_control_pk.to_bytes(), Some(&new_control_root))
2902                .await
2903                .map_err(|e| e.to_string())?,
2904        ];
2905        let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2906        let base_chunks =
2907            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())
2908                .await
2909                .map_err(|e| e.to_string())?;
2910
2911        // COMMIT to the wire: base rekey (owner's new root), then the compacted control.
2912        for c in &base_chunks {
2913            transport.publish_durable(c, &community.relays).await?;
2914        }
2915        for (_, wrap) in &carried {
2916            transport.publish_durable(wrap, &community.relays).await?;
2917        }
2918        // The Guestbook SNAPSHOT — the WHOLE POINT of the birth refound, so publish it DURABLY
2919        // and FAIL the refound if any chunk doesn't land. Unlike `refound_community` (where
2920        // live members heal via their own Join if a chunk drops), a seeded-never-landed member
2921        // CANNOT heal — omitted → absent from `memberlist()` → excluded from every future rotation
2922        // → permanently stranded. So the snapshot is load-bearing, not best-effort. The publishes
2923        // precede the local commit, so a `?`-abort leaves epoch 0 and a retry re-runs idempotently
2924        // (mint_or_reuse gives the same epoch-1 root; snapshot chunks coalesce commutatively).
2925        let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2926        let snap_id = crate::community::random_32();
2927        let snapshot_wraps: Vec<Event> = {
2928            let mut out = Vec::new();
2929            for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2930                let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2931                    .await
2932                    .map_err(|e| format!("seal birth snapshot: {e}"))?;
2933                out.push(wrap);
2934            }
2935            out
2936        };
2937        for wrap in &snapshot_wraps {
2938            transport.publish_durable(wrap, &community.relays).await?;
2939        }
2940        // Verify-back (design §4 Phase 1.5): fetch the snapshot at the new epoch and confirm every
2941        // seeded member folds, before we commit locally. A relay that ACKed a durable publish but
2942        // won't serve it back (or a partial landing) aborts here with ZERO local state — the retry
2943        // re-publishes. A seed that is (legitimately) in the folded banlist is EXPECTED to be
2944        // absent from the memberlist (`memberlist` subtracts the banlist, so requiring a
2945        // banned seed to "fold" would wedge the retry forever) — so subtract the wire-folded
2946        // banlist from the expected set. The real caller never seeds a banned member, but the
2947        // arbitrary-`snapshot_members` API must not be able to wedge on one.
2948        let verify_view = {
2949            let mut v = community.clone();
2950            v.community_root = new_root;
2951            v.root_epoch = new_epoch;
2952            v.control_pk = Some(new_control_pk);
2953            v.control_root = Some(new_control_root);
2954            v
2955        };
2956        let expected: Vec<PublicKey> = {
2957            let banlist = fetch_authority(transport, &verify_view).await.banned;
2958            snapshot_members.iter().copied()
2959                .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2960                .collect()
2961        };
2962        if !expected.is_empty() {
2963            let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2964            let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2965            if missing > 0 {
2966                return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2967            }
2968        }
2969
2970        // COMMIT locally, only now that the new root + compacted plane + snapshot are on relays.
2971        if crate::db::community::community_protocol(cid)?.is_none() {
2972            return Ok(community.clone());
2973        }
2974        let mut updated = community.clone();
2975        updated.community_root = new_root;
2976        updated.root_epoch = new_epoch;
2977        updated.control_pk = Some(new_control_pk);
2978        updated.control_root = Some(new_control_root);
2979        crate::db::community::save_community_v2(&updated)?;
2980        crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2981        for (h, _) in &carried {
2982            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2983        }
2984        Ok(updated)
2985    })
2986    .await
2987}
2988
2989/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
2990/// already archived from a prior (aborted) attempt — so a retried Refounding re-
2991/// delivers the SAME key at the same epoch/address instead of double-minting two roots
2992/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
2993/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
2994/// sentinel for a base rotation, else the channel_id hex.
2995fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2996    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2997        return Ok(existing);
2998    }
2999    let fresh = crate::community::random_32();
3000    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
3001    Ok(fresh)
3002}
3003
3004// ── The Community List (kind 33302, CORD-02 §8) ──────────────────────────────
3005
3006/// This community's MEMBERSHIP subset for the Community List (CORD-02 §8): never the
3007/// icon (a rehydrating device folds it from the Control Plane), never the link
3008/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
3009fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
3010    let hex = crate::simd::hex::bytes_to_hex_32;
3011    let channels = community
3012        .channels
3013        .iter()
3014        .filter(|c| c.private)
3015        // Keyed channels ONLY. A keyless entry is readable by this build but is
3016        // rejected outright by shipped ones (their `key` is a required String),
3017        // so emitting one would strand every older client on a stale list.
3018        .filter_map(|c| {
3019            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone(), extra: Default::default() })
3020        })
3021        .collect();
3022    super::list::JoinMaterial {
3023        community_id: hex(&community.identity.community_id.0),
3024        owner: hex(&community.identity.owner_xonly),
3025        owner_salt: hex(&community.identity.owner_salt),
3026        community_root: hex(&community.community_root),
3027        root_epoch: community.root_epoch.0,
3028        control_pk: community.control_pk.map(|p| p.to_hex()),
3029        // The list carries every private key its holder has (CORD-02 §8) — a
3030        // staffer's own devices must be able to write. Same trust class as the
3031        // community_root beside it: NIP-44-encrypted to self.
3032        control_root: community.control_root.map(|r| hex(&r)),
3033        channels,
3034        relays: community.relays.clone(),
3035        name: community.name.clone(),
3036        extra: Default::default(),
3037    }
3038}
3039
3040/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
3041/// (the material IS the membership subset of a bundle). The owner root is still
3042/// verified over the network before the community is trusted (accept_bundle).
3043fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
3044    // A keyless listing records that the channel EXISTS, not a grant — there is
3045    // nothing to seat, and it keys up when access is granted.
3046    let channels = jm
3047        .channels
3048        .iter()
3049        .filter_map(|c| {
3050            c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
3051        })
3052        .collect();
3053    CommunityInvite {
3054        community_id: jm.community_id.clone(),
3055        owner: jm.owner.clone(),
3056        owner_salt: jm.owner_salt.clone(),
3057        community_root: jm.community_root.clone(),
3058        root_epoch: jm.root_epoch,
3059        control_pk: jm.control_pk.clone(),
3060        channels,
3061        relays: jm.relays.clone(),
3062        name: jm.name.clone(),
3063        icon: None,
3064        expires_at: None,
3065        creator_npub: None,
3066        label: None,
3067        extra: Default::default(),
3068    }
3069}
3070
3071/// The account's OWN relays — where an account-level list belongs. Published to a
3072/// community's relays instead, the list stops being readable the moment that
3073/// community is left, which is exactly when its tombstone has to be found.
3074/// Empty without a client (tests), where the caller falls back to the held set.
3075async fn own_list_relays() -> Vec<String> {
3076    let Some(client) = crate::state::nostr_client() else { return Vec::new() };
3077    client
3078        .relays()
3079        .await
3080        .iter()
3081        .filter(|(_, r)| r.capabilities().load().can_write())
3082        .map(|(url, _)| url.to_string())
3083        .collect()
3084}
3085
3086/// Read the list from everywhere a copy could be: our own relays, the stock CORD
3087/// set (where it lands when ours refuse the kind), and the held communities'
3088/// (where every list published before the move still lives). Missing one of these
3089/// reads a stale copy and republishes it over a newer tombstone.
3090async fn list_read_relays(extra: &[String]) -> Vec<String> {
3091    let mut set = own_list_relays().await;
3092    set.extend(invite::stock_relays());
3093    set.extend(held_v2_relays());
3094    set.extend(extra.iter().cloned());
3095    set.sort();
3096    set.dedup();
3097    set
3098}
3099
3100/// What a fragment fetch found: the unioned list, and each index's `created_at`
3101/// so the next write to that fragment can exceed it.
3102pub struct FragSet {
3103    pub list: super::list::CommunityList,
3104    pub created_at: std::collections::BTreeMap<usize, u64>,
3105    /// `frags` as declared by the newest fragment seen.
3106    pub declared: usize,
3107    /// The winning parsed fragment per index — what the relays currently hold,
3108    /// so a rewrite can skip publishing byte-identical fragments.
3109    pub read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList>,
3110}
3111
3112impl FragSet {
3113    /// Coverage, not agreement: we hold every index below `declared`, whatever
3114    /// their individual ages. A short read is read-only, never a write.
3115    pub fn is_complete(&self) -> bool {
3116        (0..self.declared).all(|i| self.created_at.contains_key(&i))
3117    }
3118}
3119
3120/// Fetch the account's 33302 fragments and union them. Newest wins PER INDEX —
3121/// each fragment is its own addressable coordinate, so they age independently.
3122async fn fetch_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<FragSet>, String> {
3123    let signer = crate::signer::active_signer()?;
3124    let my_pk = me_pk()?;
3125    let query = Query {
3126        kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3127        authors: vec![my_pk.to_hex()],
3128        limit: Some(64),
3129        ..Default::default()
3130    };
3131    let events = transport.fetch(&query, relays).await?;
3132    if events.is_empty() {
3133        return Ok(None);
3134    }
3135    let mut newest: std::collections::BTreeMap<usize, (u64, [u8; 32], super::list_frag::FragList)> = Default::default();
3136    let mut undecryptable = 0usize;
3137    for e in events {
3138        let at = e.created_at.as_secs();
3139        let id = *e.id.as_bytes();
3140        match super::list_frag::parse_fragment_event(&signer, my_pk, &e).await {
3141            Ok((index, frag)) => {
3142                // Mirror relay resolution (CORD-02 §8): newest created_at holds the
3143                // coordinate, an age tie falls to the LOWEST event id.
3144                let wins = match newest.get(&index) {
3145                    None => true,
3146                    Some((prev_at, prev_id, _)) => at > *prev_at || (at == *prev_at && id < *prev_id),
3147                };
3148                if wins {
3149                    newest.insert(index, (at, id, frag));
3150                }
3151            }
3152            Err(_) => undecryptable += 1,
3153        }
3154    }
3155    if newest.is_empty() {
3156        crate::log_warn!("[CommunityList] {} fragment(s) fetched, none readable — treating as no news", undecryptable);
3157        return Ok(None);
3158    }
3159    // The newest fragment governs `frags`; an age tie resolves to the LARGER count
3160    // (CORD-02 §8) — too large reads an index that turns out empty, too small sends
3161    // live fragments out of range and dormant.
3162    let declared = newest
3163        .values()
3164        .max_by_key(|(at, _, f)| (*at, f.frags))
3165        .map(|(_, _, f)| f.frags)
3166        .unwrap_or(1)
3167        .max(1);
3168    let read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList> =
3169        newest.iter().map(|(i, (_, _, f))| (*i, f.clone())).collect();
3170    let frags: Vec<_> = read_frags.values().cloned().collect();
3171    let set = FragSet {
3172        list: super::list_frag::defragment(&frags),
3173        created_at: newest.iter().map(|(i, (at, _, _))| (*i, *at)).collect(),
3174        declared,
3175        read_frags,
3176    };
3177    if !set.is_complete() {
3178        crate::log_warn!(
3179            "[CommunityList] INCOMPLETE: hold {} of {} fragment(s) — reading, refusing to write",
3180            set.created_at.len(),
3181            declared
3182        );
3183    }
3184    Ok(Some(set))
3185}
3186
3187/// Publish a list as fragments. Each fragment's `created_at` must exceed that
3188/// fragment's own previous value — relays resolve an addressable event on
3189/// `created_at` alone and break a tie on the lowest event id, so a same-second
3190/// rewrite can silently discard the newer content.
3191async fn publish_fragments<T: Transport + ?Sized>(
3192    transport: &T,
3193    list: &super::list::CommunityList,
3194    prev: &std::collections::BTreeMap<usize, u64>,
3195    read: &std::collections::BTreeMap<usize, super::list_frag::FragList>,
3196    own: &[String],
3197) -> Result<usize, String> {
3198    let signer = crate::signer::active_signer()?;
3199    let my_pk = me_pk()?;
3200    let frags = super::list_frag::fragment(list);
3201    let now = now_ms() / 1000;
3202    // A fragment serializing to the bytes we just read is already on the relay:
3203    // republishing it only churns created_at — and hands a same-second sibling
3204    // write a lowest-id coin-flip it didn't need to enter.
3205    let unchanged = |index: usize, frag: &super::list_frag::FragList| -> bool {
3206        let Some(old) = read.get(&index) else { return false };
3207        matches!(
3208            (serde_json::to_string(old), serde_json::to_string(frag)),
3209            (Ok(a), Ok(b)) if a == b
3210        )
3211    };
3212    // Persisted, not level-gated: a List write that silently never lands is the failure
3213    // mode this whole format exists to end, and it is only ever diagnosed after the fact.
3214    crate::log_net_info!(
3215        "[CommunityList] publishing {} fragment(s): {} live entries ({} retired), {} tombstones",
3216        frags.len(),
3217        frags.iter().map(|f| f.entries.len()).sum::<usize>(),
3218        list.entries.len() - frags.iter().map(|f| f.entries.len()).sum::<usize>(),
3219        frags.iter().map(|f| f.tombstones.len()).sum::<usize>(),
3220    );
3221    let mut skipped = 0usize;
3222    for (index, frag) in frags.iter().enumerate() {
3223        if unchanged(index, frag) {
3224            skipped += 1;
3225            continue;
3226        }
3227        let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
3228        let event = super::list_frag::build_fragment_event(&signer, my_pk, frag, index, created_at).await?;
3229        publish_list_event(transport, &event, own).await?;
3230    }
3231    // A shrunk set leaves the fragments above it in place; empty them so a later
3232    // growth into that index cannot re-read stale memberships.
3233    for index in frags.len()..prev.len() {
3234        let empty = super::list_frag::FragList {
3235            frags: frags.len(),
3236            entries: vec![],
3237            tombstones: vec![],
3238            extra: Default::default(),
3239        };
3240        if unchanged(index, &empty) {
3241            skipped += 1;
3242            continue;
3243        }
3244        let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
3245        let event = super::list_frag::build_fragment_event(&signer, my_pk, &empty, index, created_at).await?;
3246        publish_list_event(transport, &event, own).await?;
3247    }
3248    if skipped > 0 {
3249        crate::log_net_info!("[CommunityList] {} fragment(s) skipped — byte-identical to the relay copy", skipped);
3250    }
3251    Ok(frags.len())
3252}
3253
3254/// Publish an account-level list, falling back to the stock CORD relays when our
3255/// own reject it — 33302 is a custom kind, and a relay with a kind whitelist drops
3256/// it silently. The list is the vault: it has to land somewhere every client reads.
3257async fn publish_list_event<T: Transport + ?Sized>(transport: &T, event: &Event, own: &[String]) -> Result<(), String> {
3258    if !own.is_empty() {
3259        match transport.publish(event, own).await {
3260            Ok(()) => return Ok(()),
3261            Err(e) => crate::log_warn!("[CommunityList] own relays refused the list ({}) — falling back to the stock set", e),
3262        }
3263    }
3264    transport.publish(event, &invite::stock_relays()).await
3265}
3266
3267/// The union of every held v2 community's relays — a fallback write target, and
3268/// where every list published before the move to our own relays still lives.
3269fn held_v2_relays() -> Vec<String> {
3270    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3271    if let Ok(ids) = crate::db::community::list_community_ids() {
3272        for id in ids {
3273            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
3274                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
3275                    set.extend(c.relays);
3276                }
3277            }
3278        }
3279    }
3280    set.into_iter().collect()
3281}
3282
3283/// Rebuild this account's Community List from its held v2 communities, MERGE with the remote
3284/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
3285/// `just_joined` is the community THIS call is recording a create/join for — the
3286/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
3287/// (a deliberate re-join resurrects). Every OTHER held community that the remote
3288/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
3289/// because we joined something else — the W1 resurrection hole). Idempotent;
3290/// best-effort — a list-publish failure never fails the membership change itself.
3291/// Returns `Ok(true)` when the list was PUBLISHED, `Ok(false)` when the attempt was
3292/// skipped without failing the caller (a failed remote fetch — see below). Callers that
3293/// need the membership to actually land use [`republish_community_list_durable`].
3294pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
3295    crate::db::scoped(async move {
3296        let held = held_v2_relays();
3297        if held.is_empty() {
3298            return Ok(false); // nothing held → nothing to sync
3299        }
3300        let relays = list_read_relays(&[]).await;
3301        let own = own_list_relays().await;
3302        // Written to our own relays, read from both: the copy has to outlive any
3303        // one membership. Falls back to the held set when we have no relays of
3304        // our own to write to.
3305        let write_relays = if own.is_empty() { held.clone() } else { own };
3306        // A FAILED remote fetch must not drive this replaceable-event write: publishing
3307        // a list built without the remote seeds would drop older-epoch backfill anchors
3308        // and re-stamp add-times (the W2 seed-regression + a resurrection window).
3309        let (remote, prev_created, read_frags) = match fetch_fragments(transport, &relays).await {
3310            // No fragments yet: this is the first write under §8, and local state
3311            // is the source. Nothing to merge, nothing to lose.
3312            Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
3313            Ok(Some(set)) if !set.is_complete() => {
3314                crate::log_net_fail!(
3315                    "[CommunityList] republish SKIPPED — hold {} of {} fragments; rewriting a set we haven't fully read would drop the memberships in the ones we're missing",
3316                    set.created_at.len(),
3317                    set.declared
3318                );
3319                return Ok(false);
3320            }
3321            Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
3322            Err(e) => {
3323                // SILENT-SKIP HAZARD: bailing is correct (publishing a list built without the
3324                // remote seeds drops backfill anchors), but the membership this call was meant
3325                // to record is now simply unrecorded. A join that lands here leaves a community
3326                // held locally with no list entry — and if it also carries an older tombstone,
3327                // nothing ever out-ranks it again. Say so loudly; `Ok(())` keeps it non-fatal.
3328                crate::log_warn!(
3329                    "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
3330                    e,
3331                    just_joined
3332                        .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
3333                        .unwrap_or_default()
3334                );
3335                return Ok(false);
3336            }
3337        };
3338        let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
3339        let now = now_ms();
3340        let mut local = super::list::CommunityList::default();
3341        for id in crate::db::community::list_community_ids()? {
3342            if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
3343                continue;
3344            }
3345            let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
3346            let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
3347            let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
3348            // A held community the remote has tombstoned (a sibling device left it) that
3349            // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
3350            // different community would silently undo the leave everywhere.
3351            //
3352            // UNLESS our hold POST-DATES the removal. A rejoin whose membership never
3353            // reached the list (this publish is best-effort — a failed remote fetch
3354            // silently skips it) leaves a tombstone with no entry, and nothing can ever
3355            // out-rank it again: every boot the list sync reads "removed", tears the
3356            // community down, the rejoin re-adds it, and it loops forever. Our own hold
3357            // is first-hand evidence of membership, so let it settle the tie by the same
3358            // add-vs-remove rule the list already uses everywhere else.
3359            let tombstoned_at = remote
3360                .tombstones
3361                .iter()
3362                .find(|t| t.community_id == cid_hex)
3363                .map(|t| t.removed_at)
3364                .unwrap_or(0);
3365            let held_since = c.created_at_ms;
3366            if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
3367                crate::log_warn!(
3368                    "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
3369                    &cid_hex[..8], tombstoned_at, held_since
3370                );
3371                continue;
3372            }
3373            // Keep an already-live entry's add time (no churn); the joined community (or a
3374            // genuinely-new one) stamps `now` so a re-join beats a stale tombstone. A hold
3375            // that outlived a tombstone re-asserts itself at its own join time, which is
3376            // already newer than the removal.
3377            let added_at = if remote.is_live(&cid_hex) && !is_join {
3378                remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
3379            } else if !is_join && tombstoned_at > 0 {
3380                held_since
3381            } else {
3382                now
3383            };
3384            let jm = join_material(&c);
3385            local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
3386        }
3387        let merged = remote.merge(&local);
3388        // No whole-list cap: fragmentation is what keeps each event publishable,
3389        // and the count limit is gone (CORD-02 §8).
3390        match publish_fragments(transport, &merged, &prev_created, &read_frags, &write_relays).await {
3391            Ok(_) => {}
3392            Err(e) => {
3393                crate::log_net_fail!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
3394                return Err(e);
3395            }
3396        }
3397        Ok(true)
3398    })
3399    .await
3400}
3401
3402/// Retry budget for [`republish_community_list_durable`]. An unrecorded membership is
3403/// invisible to the user and self-heals only on their NEXT join, so ride out a relay
3404/// blip rather than a single shot. Bounded: a permanently dead relay set gives up
3405/// instead of spinning.
3406const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3407
3408/// Record a membership across devices DURABLY: retry in the background until the list
3409/// actually lands.
3410///
3411/// [`republish_community_list`] must never fail a join, and it deliberately publishes
3412/// NOTHING when the remote fetch fails (a list built without the remote seeds would drop
3413/// other devices' entries). One shot at that means a relay blip during a join leaves the
3414/// membership unrecorded until the user happens to join something else — and if a stale
3415/// tombstone out-ranks it, the community is stranded until a manual leave+rejoin.
3416///
3417/// Non-blocking. Skipped entirely without a live client (headless/unit tests drive the
3418/// generic fn directly). Bound to its account, so a swap mid-backoff leaves it
3419/// publishing A's list from A's client rather than from B's.
3420pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3421    if crate::state::nostr_client().is_none() {
3422        return;
3423    }
3424    crate::db::spawn_bound(async move {
3425        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3426            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3427            match republish_community_list(&transport, just_joined.as_ref()).await {
3428                Ok(true) => {
3429                    if attempt > 0 {
3430                        crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3431                    }
3432                    return;
3433                }
3434                Ok(false) => {} // skipped (remote fetch failed) — already logged; retry
3435                Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3436            }
3437            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3438        }
3439        crate::log_warn!(
3440            "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3441            LIST_REPUBLISH_BACKOFF_SECS.len()
3442        );
3443    });
3444}
3445
3446/// Record a permanent leave tombstone for `community_id` in the List. Written to
3447/// our OWN relays: the departing community's are about to stop being read at all,
3448/// so a tombstone left only there is one no later sync can ever fetch — the
3449/// community then rejoins itself from a stale copy on the next boot.
3450async fn tombstone_community_list<T: Transport + ?Sized>(
3451    transport: &T,
3452    community_id: &crate::community::CommunityId,
3453    relays: &[String],
3454    removed_at: u64,
3455) -> Result<(), String> {
3456    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3457    let read_relays = list_read_relays(relays).await;
3458    let own = own_list_relays().await;
3459    let write_relays = if own.is_empty() { relays.to_vec() } else { own };
3460    // A failed fetch here would drop other communities' entries (only the
3461    // tombstone would survive); preserve them by bailing — the leave re-records
3462    // on the next attempt, and the local teardown already happened.
3463    let (mut doc, prev_created, read_frags) = match fetch_fragments(transport, &read_relays).await {
3464        Ok(Some(set)) if !set.is_complete() => {
3465            return Err(format!("hold {} of {} fragments — refusing to rewrite a set we haven't fully read", set.created_at.len(), set.declared));
3466        }
3467        Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
3468        Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
3469        Err(e) => return Err(e),
3470    };
3471    doc.tombstones.retain(|t| t.community_id != cid_hex);
3472    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at, extra: Default::default() });
3473    // No size gate on the way out: a tombstone strictly shrinks the live set, and
3474    // a guard that blocks the only operation able to restore compliance is a
3475    // deadlock, not a guard (CORD-02 §8).
3476    publish_fragments(transport, &doc, &prev_created, &read_frags, &write_relays).await.map(|_| ())
3477}
3478
3479/// Retry a leave tombstone until it lands, on the same budget a join gets. A leave
3480/// that fails to record is the worse of the two: the local hold is already gone, so
3481/// this device shows the community left while every other one still holds it, and
3482/// nothing re-records it until some unrelated edit happens to carry it.
3483///
3484/// `removed_at` is the caller's, not `now` — re-stamping it here would let a retry
3485/// that fires after a genuine rejoin bury that rejoin.
3486pub fn tombstone_community_list_durable(community_id: crate::community::CommunityId, relays: Vec<String>, removed_at: u64) {
3487    if crate::state::nostr_client().is_none() {
3488        return;
3489    }
3490    crate::db::spawn_bound(async move {
3491        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3492            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3493            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3494            match tombstone_community_list(&transport, &community_id, &relays, removed_at).await {
3495                Ok(()) => {
3496                    crate::log_net_info!(
3497                        "[CommunityList] leave tombstone for {} landed on retry #{}",
3498                        &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
3499                        attempt + 1
3500                    );
3501                    return;
3502                }
3503                Err(e) => crate::log_warn!("[CommunityList] leave tombstone retry #{} failed: {}", attempt + 1, e),
3504            }
3505        }
3506        crate::log_net_fail!(
3507            "[CommunityList] leave tombstone for {} NEVER landed after {} attempts — it will come back on another device",
3508            &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
3509            LIST_REPUBLISH_BACKOFF_SECS.len()
3510        );
3511    });
3512}
3513
3514/// Sync memberships from the Community List across devices: fetch this account's list from
3515/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
3516/// a fresh device), and JOIN every live entry not already held — reconstructing the
3517/// community from its join material and re-verifying the owner root. Returns the
3518/// newly-rehydrated communities (so the caller can subscribe + notify).
3519/// What one Community-List sync changed locally.
3520pub struct ListSyncOutcome {
3521    /// Communities newly adopted from the list (already persisted + chat-registered).
3522    pub joined: Vec<CommunityV2>,
3523    /// Communities a sibling device LEFT, as `(community_id_hex, channel_id_hexes)`.
3524    ///
3525    /// The rows are already gone here, so the ids are captured BEFORE deletion: the caller
3526    /// still has to finish the local teardown (chat rows, STATE, the live subscription),
3527    /// and it can't look them up afterwards. Deleting the community while leaving its chat
3528    /// row behind is what produces a ghost "0 Members" room pointing at nothing.
3529    pub removed: Vec<(String, Vec<String>)>,
3530}
3531
3532/// Marks that this account has written the fragmented list at least once.
3533const LIST_SEEDED_KEY: &str = "community_list_frag_seeded";
3534
3535/// The join refusal for a VERIFIED dissolution — an owner-signed, identity-bound
3536/// tombstone (§9: no un-dissolve). A sentinel because the accept boundary matches
3537/// on it: this is the one join failure that is a verdict rather than a maybe, so
3538/// the parked invite is retired instead of kept for a retry that can never succeed.
3539pub const ERR_DISSOLVED: &str = "this community has been dissolved";
3540
3541/// Retire a parked invite whose community is provably dead: drop the local row and
3542/// tombstone the §8 List so every sibling device purges its copy on the next sync.
3543/// ONLY for the dissolved verdict — ambiguous could-not-verify failures stay parked.
3544pub async fn retire_dead_invite<T: Transport + ?Sized>(transport: &T, community_id_hex: &str, relays: &[String]) {
3545    let _ = crate::db::community::delete_pending_invite(community_id_hex);
3546    if let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(community_id_hex) {
3547        let id = crate::community::CommunityId(cid);
3548        let at = now_ms();
3549        if tombstone_community_list(transport, &id, &relays.to_vec(), at).await.is_err() {
3550            // The row is already gone locally; make sure the suppression still
3551            // reaches sibling devices once the relays cooperate.
3552            tombstone_community_list_durable(id, relays.to_vec(), at);
3553        }
3554    }
3555    crate::emit_event("community_invites_purged", &serde_json::json!({}));
3556}
3557
3558/// Every relay answered EOSE with zero fragments — the only reading of "empty"
3559/// strong enough to seed over. The aggregate fetch that precedes this cannot
3560/// distinguish "no fragments" from "the relay holding them never answered";
3561/// asked one at a time, an error is a FAILED read and a failed read never seeds.
3562async fn confirmed_no_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> bool {
3563    let Ok(my_pk) = me_pk() else { return false };
3564    if relays.is_empty() {
3565        return false;
3566    }
3567    let query = Query {
3568        kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3569        authors: vec![my_pk.to_hex()],
3570        limit: Some(1),
3571        ..Default::default()
3572    };
3573    for relay in relays {
3574        match transport.fetch(&query, std::slice::from_ref(relay)).await {
3575            Ok(events) if events.is_empty() => {}
3576            // Found one (the aggregate read raced a sibling's write) or the relay
3577            // didn't answer — either way this account is not confirmed empty.
3578            _ => return false,
3579        }
3580    }
3581    true
3582}
3583
3584/// First write of the §8 List for an account that has none: an account upgrading
3585/// from the retired single-event list arrives here with memberships that exist
3586/// only in local state, and nothing else in the stack publishes without a
3587/// membership change to record. Latched once it lands, so a boot-load read that
3588/// comes back empty can never republish local state over a sibling's tombstones.
3589async fn seed_community_list<T: Transport + ?Sized>(transport: &T) {
3590    if crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).ok().flatten().is_some() {
3591        return;
3592    }
3593    if held_v2_relays().is_empty() {
3594        return;
3595    }
3596    // Seeding over a read that merely FAILED publishes fragment 0 over a sibling
3597    // device's tombstones at a fresh created_at — confirm the emptiness per relay.
3598    let own = own_list_relays().await;
3599    let confirm = if own.is_empty() { held_v2_relays() } else { own };
3600    if !confirmed_no_fragments(transport, &confirm).await {
3601        crate::log_warn!("[CommunityList] seed DEFERRED — the empty read is unconfirmed; retrying on a later boot");
3602        return;
3603    }
3604    crate::log_net_info!("[CommunityList] no fragments held anywhere (confirmed per relay) — seeding from local state");
3605    match republish_community_list(transport, None).await {
3606        Ok(true) => {
3607            let _ = crate::db::settings::set_sql_setting(LIST_SEEDED_KEY.to_string(), "1".to_string());
3608        }
3609        Ok(false) => {}
3610        Err(e) => crate::log_net_fail!("[CommunityList] seed failed: {e}"),
3611    }
3612}
3613
3614pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3615    crate::db::scoped(async move {
3616        let relays = list_read_relays(bootstrap_relays).await;
3617        if relays.is_empty() {
3618            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3619        }
3620        // A cross-device sync that finds nothing is indistinguishable from one that
3621        // never ran, so every exit says why — this path is only ever debugged after
3622        // the fact, from a user's log.
3623        let list = match fetch_fragments(transport, &relays).await {
3624            Ok(Some(set)) => {
3625                crate::log_net_info!(
3626                    "[CommunityList] fetched: {} of {} fragment(s), {} entries, {} tombstones, across {} relays",
3627                    set.created_at.len(),
3628                    set.declared,
3629                    set.list.entries.len(),
3630                    set.list.tombstones.len(),
3631                    relays.len()
3632                );
3633                set.list
3634            }
3635            Ok(None) => {
3636                // Transient by nature: boot runs many concurrent passes and a relay that
3637                // times out under that load returns nothing. Only persistent absence
3638                // matters, and that shows up as "adopted nothing" anyway.
3639                crate::log_debug!("[CommunityList] no fragments across {} relays", relays.len());
3640                seed_community_list(transport).await;
3641                return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3642            }
3643            Err(e) => {
3644                crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3645                return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3646            }
3647        };
3648        // Receive-side teardown (the counterpart to the republish tombstone guard):
3649        // a community this device still holds but the synced list shows TOMBSTONED (a
3650        // sibling device left it) and NOT live gets torn down here, so a leave on one
3651        // device propagates to the others. A re-join would have re-added it live
3652        // (beating the tombstone), so is_live short-circuits the honest case.
3653        let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3654        for t in &list.tombstones {
3655            if list.is_live(&t.community_id) {
3656                continue;
3657            }
3658            let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3659            let id = crate::community::CommunityId(cid);
3660            let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3661                continue; // not held — nothing to tear down
3662            };
3663            // `is_live` above assumes a rejoin re-added an entry, but recording that entry is
3664            // best-effort: a relay blip at join time leaves the tombstone unopposed forever, and
3665            // this would then delete the community on every sync. So let the LOCAL hold break the
3666            // tie too — a hold created after the removal IS the rejoin, whether or not its entry
3667            // ever reached the list. Same rule the v1 sweep uses.
3668            if held.created_at_ms > t.removed_at {
3669                crate::log_warn!(
3670                    "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3671                    &t.community_id[..8], t.removed_at, held.created_at_ms
3672                );
3673                continue;
3674            }
3675            let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3676            let _ = crate::db::community::delete_community(&t.community_id);
3677            removed.push((t.community_id.clone(), channel_ids));
3678        }
3679        // A tombstone also retires any PARKED invite it post-dates — a decline or a
3680        // dissolution-retire made on another device. Supersession, not a bare id
3681        // match: only a removal NEWER than the invite's arrival is a verdict on it;
3682        // an older tombstone is a past leave the re-invite already superseded
3683        // (received_at is seconds, removed_at is ms).
3684        let mut invites_purged = 0usize;
3685        for t in &list.tombstones {
3686            if list.is_live(&t.community_id) {
3687                continue;
3688            }
3689            let Ok(Some(received_at)) = crate::db::community::pending_invite_received_at(&t.community_id) else { continue };
3690            if (received_at.max(0) as u64).saturating_mul(1000) <= t.removed_at
3691                && crate::db::community::delete_pending_invite(&t.community_id).is_ok()
3692            {
3693                invites_purged += 1;
3694            }
3695        }
3696        if invites_purged > 0 {
3697            crate::emit_event("community_invites_purged", &serde_json::json!({}));
3698        }
3699        let mut joined = Vec::new();
3700        for entry in list.live_entries() {
3701            let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3702            // Held under ANY protocol, not just v2. A protocol-scoped check re-adopts an
3703            // id we already hold as v1: verification correctly fails (there is no v2
3704            // community at that coordinate), and the entry is retried on EVERY sync pass
3705            // forever — a permanent warning flood plus a wasted multi-relay walk each time.
3706            if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
3707                continue; // already held
3708            }
3709            // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
3710            // and seeds floors. NO Guestbook Join: this device is receiving keys the
3711            // account already holds elsewhere — the membership was announced when it
3712            // actually joined, and a key sync is not a membership event.
3713            let bundle = material_to_invite(&entry.current);
3714            match accept_bundle(transport, &bundle, None, false).await {
3715                Ok(community) => {
3716                    // The staff write secret never rides a bundle shape — adopt it
3717                    // from the list entry directly, and only when it derives to the
3718                    // control_pk held for exactly this epoch (CORD-02 §5/§8). A
3719                    // stale secret fails closed to read-only; a 136-byte base blob
3720                    // or the Grant's control_wrap re-delivers on the walk forward.
3721                    if let (Some(pk), Some(root_hex)) = (community.control_pk, entry.current.control_root.as_deref()) {
3722                        if let Some(root) = crate::simd::hex::hex_to_bytes_32_checked(root_hex) {
3723                            if super::derive::control_signer_group_key(&root, community.id(), community.root_epoch).pk() == pk
3724
3725                            {
3726                                let mut held = community.clone();
3727                                held.control_root = Some(root);
3728                                let _ = crate::db::community::save_community_v2(&held);
3729                            }
3730                        }
3731                    }
3732                    joined.push(community);
3733                }
3734                // A listed-but-unadoptable entry is the failure mode that reads as
3735                // "cross-device sync is broken": the community never appears and any
3736                // parked invite for it is never retired.
3737                Err(e) => crate::log_net_fail!(
3738                    "[CommunityList] {} is listed but adoption failed: {e}",
3739                    &entry.community_id[..entry.community_id.len().min(8)]
3740                ),
3741            }
3742        }
3743        Ok(ListSyncOutcome { joined, removed })
3744    })
3745    .await
3746}
3747
3748// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
3749
3750/// Publish one control edition (a role, grant, banlist, community-metadata, or
3751/// channel-metadata edit) at the next version for its entity, chaining `prev` from
3752/// our held head, and advance our local floor. Authority is enforced by every
3753/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
3754/// requires only a valid local signer; a well-behaved client checks its own rank
3755/// first, but a reader drops an unauthorized edition regardless.
3756/// This actor's authority citation for a control edition (CORD-04 §5): the head
3757/// of their OWN Grant entity, pinned by coordinate + version + edition hash.
3758///
3759/// A SYNC FLOOR, not a verdict — a verifier refuses to act until it has synced
3760/// at least this Grant, then resolves rank against its CURRENT roster, so a
3761/// demoted admin is never grandfathered by an old-but-once-valid citation.
3762///
3763/// `None` for the owner (supreme, rank comes from the community id) and `None`
3764/// when no Grant head is held — an actor who cannot cite has no rank to claim,
3765/// and the edition is dropped by a conforming reader either way.
3766/// The verify half of [`my_authority_citation`] (CORD-04 §5): does the actor's
3767/// cited Grant prove authority we have actually SYNCED? The owner is supreme and
3768/// cites nothing. A non-owner MUST cite, and we must hold that Grant at ≥ the
3769/// cited version with the cited hash at the tip — else fail closed, because
3770/// honoring an action whose authority we can't confirm is exactly how a demoted
3771/// moderator keeps moderating.
3772///
3773/// Completeness only: the permission + outrank is the separate roster check, so a
3774/// since-demoted actor is refused there (refuse-superseded). An action citing a
3775/// version we haven't synced parks and is re-judged on the next roster sync — the
3776/// sync path can't escalate to a blocking fetch.
3777pub(super) fn citation_is_synced(
3778    cid_hex: &str,
3779    owner_hex: &str,
3780    actor_hex: &str,
3781    citation: Option<&crate::community::edition::AuthorityCitation>,
3782) -> bool {
3783    if owner_hex == actor_hex {
3784        return true;
3785    }
3786    if citation.is_none() {
3787        return false;
3788    }
3789    let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3790    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3791    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3792        &crate::community::CommunityId(cid_bytes),
3793        &actor_bytes,
3794    ));
3795    let head: Vec<crate::community::roster::EntityHead> =
3796        crate::db::community::get_edition_head(cid_hex, &grant_hex)
3797            .ok()
3798            .flatten()
3799            .map(|(version, self_hash)| crate::community::roster::EntityHead {
3800                entity_hex: grant_hex.clone(),
3801                version,
3802                self_hash,
3803                inner_id: [0u8; 32],
3804                citation: None,
3805            })
3806            .into_iter()
3807            .collect();
3808    crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3809}
3810
3811/// [`my_authority_citation`], but refusing to emit an action every reader will
3812/// drop (CORD-04 §5: an uncited non-owner action is not honored).
3813///
3814/// The citation is built from PERSISTED heads, which only `follow_control` writes
3815/// — so an admin who hasn't folded yet (just promoted, or freshly restored) would
3816/// otherwise publish uncited and have the action silently vanish on every client,
3817/// with nothing shown locally. Failing here turns that into one retryable error.
3818fn required_authority_citation(
3819    community: &CommunityV2,
3820    actor: &PublicKey,
3821) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3822    if community.owner().ok().as_ref() == Some(actor) {
3823        return Ok(None); // supreme, cites nothing
3824    }
3825    my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3826        "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3827    })
3828}
3829
3830fn my_authority_citation(
3831    community: &CommunityV2,
3832    actor: &PublicKey,
3833) -> Option<crate::community::edition::AuthorityCitation> {
3834    if community.owner().ok().as_ref() == Some(actor) {
3835        return None;
3836    }
3837    let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3838    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3839    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3840    crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3841        .ok()
3842        .flatten()
3843        .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3844}
3845
3846/// Refuse a root-derived write whose in-hand struct predates a rotation.
3847///
3848/// A Ban's refound buries the old root while the caller's `CommunityV2` still
3849/// points at it; publishing there lands on a plane nobody folds — the action
3850/// "succeeds" and silently never happened (an unban that doesn't unban, an
3851/// invite that strands its joiner on a dead epoch). Failing loudly instead lets
3852/// the caller reload and retry against the living root.
3853fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3854    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3855    match crate::db::community::get_server_root_epoch(&cid_hex)? {
3856        Some(held) if held != community.root_epoch.0 => Err(format!(
3857            "the community re-founded mid-action (epoch {} -> {held}); retry",
3858            community.root_epoch.0
3859        )),
3860        _ => Ok(()), // no row = a not-yet-persisted create; nothing newer to defer to
3861    }
3862}
3863
3864async fn publish_control_edition<T: Transport + ?Sized>(
3865    transport: &T,
3866    community: &CommunityV2,
3867    vsk: &str,
3868    entity_id: &[u8; 32],
3869    content: &str,
3870) -> Result<(), String> {
3871    crate::db::scoped(async move {
3872        assert_current_root(community)?;
3873        let signer = crate::signer::active_signer()?;
3874        let my_pk = me_pk()?;
3875        // On a split epoch the signing secret is the staff-held control_root
3876        // (CORD-02 §2) — a member without it fails HERE with a readable error
3877        // instead of minting a wrap every reader and relay drops.
3878        let view = control::ControlPlane::of(community);
3879        let control = view.write_group()?;
3880        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3881        let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3882        let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3883            Some((v, h)) => (v + 1, Some(h)),
3884            None => (1, None),
3885        };
3886        // CORD-04 §5: a non-owner names the exact Grant edition it claims its rank
3887        // under. Computed here rather than passed in — the citation is a property of
3888        // WHO IS ACTING, identical for every entity kind, so deciding it per call
3889        // site is nine chances to forget (and nine were, silently: every site passed
3890        // None). The owner cites nothing; their rank is the community id itself.
3891        let citation = required_authority_citation(community, &my_pk)?;
3892        let at = now_ms() / 1000;
3893        let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3894        let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3895        transport.publish(&wrap, &community.relays).await?;
3896        // Advance our own floor so a follow-up edit chains from this head and refuse-
3897        // downgrade holds; open our own wrap to recover the self_hash + inner_id.
3898        // Re-check the session AFTER the publish await: a swap mid-publish means the
3899        // pool now points at another account's DB — skipping is safe (the next own
3900        // edit rebuilds the same head from the relay's copy).
3901        if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3902            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3903        }
3904        Ok(())
3905    })
3906    .await
3907}
3908
3909/// Merge our OWN just-published Role/Grant into the locally stored roster.
3910///
3911/// v2 persists the roster only inside `follow_control`, so a role or grant we
3912/// just published is invisible to every sync local read (entitlement, capability
3913/// gates, the next grant) until the next fold. This writes what we are already
3914/// authorized to have written; the next fold recomputes from the plane and
3915/// converges. Mirrors the fold's own write, so the stored `roles_at` is left
3916/// alone — a real edition always outranks this optimistic merge.
3917fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3918    let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3919    if let Some(r) = role {
3920        match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3921            Some(slot) => *slot = r.clone(),
3922            None => roster.roles.push(r.clone()),
3923        }
3924    }
3925    if let Some(g) = grant {
3926        match roster.grants.iter_mut().find(|x| x.member == g.member) {
3927            Some(slot) => *slot = g.clone(),
3928            None => roster.grants.push(g.clone()),
3929        }
3930    }
3931    let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3932    if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3933        crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3934    }
3935}
3936
3937/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
3938/// rename or permission change is a versioned edit of the same id. Gated on the
3939/// reader side by `MANAGE_ROLES` + outrank.
3940pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3941    super::roles::validate_role(role)?;
3942    let content = super::roles::role_content_json(role)?;
3943    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3944    publish_control_edition(transport, community, vsk::ROLE, &role_id, &content).await
3945}
3946
3947/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
3948/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
3949/// member. Staff-ness (whether the Grant must deliver the `control_root`,
3950/// CORD-04 §3) is judged from the persisted roster; internal callers holding a
3951/// fresher fetched view use [`grant_roles_with_roster`].
3952pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3953    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3954    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3955    // A role this device hasn't folded is unjudgeable for staff-ness, and
3956    // guessing "not staff" publishes a staff-making Grant with NO control_wrap
3957    // — seating a staffer who cannot write, silently (CORD-04 §3 makes the wrap
3958    // mandatory on such a Grant). Resolve it from the network first; only the
3959    // definitions still missing after that fall back to the local view.
3960    // Internal callers already holding a fetched roster use
3961    // `grant_roles_with_roster` and skip this round trip.
3962    let roster = if community.control_pk.is_some() && role_ids.iter().any(|rid| roster.role(rid).is_none()) {
3963        let mut fetched = fetch_authority(transport, community).await.roles;
3964        for r in roster.roles {
3965            if !fetched.roles.iter().any(|x| x.role_id == r.role_id) {
3966                fetched.roles.push(r);
3967            }
3968        }
3969        fetched
3970    } else {
3971        roster
3972    };
3973    grant_roles_with_roster(transport, community, member, role_ids, &roster).await
3974}
3975
3976/// The `control_wrap` a Grant carries when its role set leaves `member` staff
3977/// (CORD-04 §3): the current epoch's `control_root`, NIP-44-encrypted under
3978/// the granter↔member pairwise key, `epoch_be[8] ‖ control_root[32]` inside.
3979/// `None` when nothing is owed — a legacy pre-split epoch, or a grant that
3980/// leaves the member non-staff. Attached on EVERY staff-leaving grant, not
3981/// just the first: a re-issue with a fresh wrap is the spec's own re-delivery
3982/// path (a lost key, a head superseded before its member fetched it), and the
3983/// marginal cost is one ECDH.
3984///
3985/// Errs when a wrap is owed but this granter cannot mint one: a staff-making
3986/// edition MUST carry a wrap fresh for the current epoch — publishing without
3987/// it would seat a staffer who cannot write, silently. (The publish itself
3988/// would fail on the same missing secret, but the promotion must never outrun
3989/// the delivery.)
3990async fn control_wrap_owed(community: &CommunityV2, makes_staff: bool, member: &PublicKey) -> Result<Option<String>, String> {
3991    if !makes_staff {
3992        return Ok(None);
3993    }
3994    let Some(pk) = community.control_pk else {
3995        return Ok(None); // legacy epoch — the plane has no write key to deliver
3996    };
3997    let Some(cr) = community.control_root else {
3998        return Err("you don't hold this community's staff write key, so you can't promote to staff yet".to_string());
3999    };
4000    // Never deliver a secret that doesn't derive to the held address — the
4001    // recipient's fail-closed check would drop it anyway (CORD-04 §3).
4002    if super::derive::control_signer_group_key(&cr, community.id(), community.root_epoch).pk() != pk {
4003        return Err("this community's held staff write key is corrupt; re-sync before promoting".to_string());
4004    }
4005    use nostr_sdk::prelude::AsyncNip44;
4006    let signer = crate::signer::active_signer()?;
4007    let wrap = signer
4008        .nip44_encrypt_async(member, &super::rekey::control_wrap_b64(community.root_epoch, &cr))
4009        .await
4010        .map_err(|e| format!("staff key delivery: {e}"))?;
4011    Ok(Some(wrap))
4012}
4013
4014/// [`grant_roles`] judging staff-ness against a caller-supplied roster — the
4015/// freshest view the caller holds (a fetched authority merge, or one carrying
4016/// a role minted moments ago that no fold has read back).
4017async fn grant_roles_with_roster<T: Transport + ?Sized>(
4018    transport: &T,
4019    community: &CommunityV2,
4020    member: &PublicKey,
4021    role_ids: Vec<String>,
4022    roster: &crate::community::roles::CommunityRoles,
4023) -> Result<(), String> {
4024    // A grant that leaves the member staff carries the staff write key in the
4025    // edition itself (CORD-04 §3) — promotion and delivery are one signed
4026    // edition: nothing separate to send, race, or watch an inbox for.
4027    let control_wrap = control_wrap_owed(community, roster.roles_make_staff(&role_ids), member).await?;
4028    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
4029    let content = super::roles::grant_content_json_with_wrap(&grant, control_wrap)?;
4030    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
4031    publish_control_edition(transport, community, vsk::GRANT, &eid, &content).await
4032}
4033
4034/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
4035/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
4036/// every device, so concurrent grants converge as editions of ONE entity instead
4037/// of forking two Admin roles.
4038pub async fn ensure_admin_role<T: Transport + ?Sized>(
4039    transport: &T,
4040    community: &CommunityV2,
4041    view: &AuthorityView,
4042    create_if_missing: bool,
4043) -> Result<Option<String>, String> {
4044    use crate::community::roles::{Permissions, Role, RoleScope};
4045    // The finder tests the FROZEN founding mask, never ADMIN_ALL: published
4046    // Admin roles predate later bits (PIN_MESSAGES...), and requiring a bit
4047    // they can't have would orphan every one of them and mint a duplicate.
4048    if let Some(r) = view
4049        .roles
4050        .roles
4051        .iter()
4052        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
4053    {
4054        return Ok(Some(r.role_id.clone()));
4055    }
4056    if !create_if_missing {
4057        return Ok(None);
4058    }
4059    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4060    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
4061    set_role(transport, community, &Role::admin(role_id.clone())).await?;
4062    Ok(Some(role_id))
4063}
4064
4065/// Grant the @admin role (minting it deterministically when absent), MERGED into
4066/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
4067/// blind push would erase their other roles. Owner-only: the position-1 Admin is
4068/// manageable only by position 0 (an equal never outranks it), and refusing
4069/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
4070/// entity from advancing this device's own floor onto a head readers reject.
4071pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4072    crate::db::scoped(async move {
4073        // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
4074        // downstream publish's own (post-swap) guard write account A's floor into B.
4075        let my_pk = me_pk()?;
4076        if my_pk != community.owner()? {
4077            return Err("only the community owner can grant @admin".to_string());
4078        }
4079        let view = fetch_authority(transport, community).await;
4080        let member_hex = member.to_hex();
4081        require_grant_head(community, &view, &member_hex)?;
4082        let role_id = ensure_admin_role(transport, community, &view, true)
4083            .await?
4084            .expect("create_if_missing yields an id");
4085        let mut role_ids = view
4086            .roles
4087            .grants
4088            .iter()
4089            .find(|g| g.member == member_hex)
4090            .map(|g| g.role_ids.clone())
4091            .unwrap_or_default();
4092        if role_ids.contains(&role_id) {
4093            return Ok(()); // already admin — don't bump the grant edition for nothing.
4094        }
4095        role_ids.push(role_id.clone());
4096        // Judge staff-ness against a roster that HOLDS the admin definition: a
4097        // just-minted role hasn't folded into the fetched view yet, and missing it
4098        // would seat an admin without the write key (CORD-04 §3).
4099        let mut roster = view.roles.clone();
4100        if roster.role(&role_id).is_none() {
4101            roster.roles.push(crate::community::roles::Role::admin(role_id));
4102        }
4103        grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4104    })
4105    .await
4106}
4107
4108/// Strip the @admin role from the member's grant, preserving their other roles.
4109/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
4110pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4111    crate::db::scoped(async move {
4112        let my_pk = me_pk()?;
4113        if my_pk != community.owner()? {
4114            return Err("only the community owner can revoke @admin".to_string());
4115        }
4116        let view = fetch_authority(transport, community).await;
4117        let member_hex = member.to_hex();
4118        require_grant_head(community, &view, &member_hex)?;
4119        let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
4120            return Ok(()); // no admin role exists — nothing to revoke.
4121        };
4122        let mut role_ids = view
4123            .roles
4124            .grants
4125            .iter()
4126            .find(|g| g.member == member_hex)
4127            .map(|g| g.role_ids.clone())
4128            .unwrap_or_default();
4129        let before = role_ids.len();
4130        role_ids.retain(|r| r != &role_id);
4131        if role_ids.len() == before {
4132            return Ok(());
4133        }
4134        // A demotion can still leave the member staff through a KEPT role — the
4135        // fetched view holds every kept definition, so judge against it.
4136        grant_roles_with_roster(transport, community, member, role_ids, &view.roles).await
4137    })
4138    .await
4139}
4140
4141/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
4142/// locally but no head folded (withheld / evicted): a blind push at that point
4143/// would erase their other roles at a higher version.
4144fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
4145    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
4146        return Err("malformed member key".to_string());
4147    };
4148    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
4149    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
4150        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
4151    }
4152    Ok(())
4153}
4154
4155/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
4156/// whole list on every edit. Gated on the reader side by `BAN` per head plus strict
4157/// outrank per entry — mirrored here BEFORE the publish, so an unauthorized caller
4158/// (an SDK bot without the bit, a demoted moderator) fails loudly instead of
4159/// publishing an edition every reader silently rejects while its own local echo
4160/// caches the phantom ban.
4161pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
4162    crate::db::scoped(async move {
4163        super::roles::validate_banlist(banned)?;
4164        {
4165            let my_pk = me_pk()?;
4166            let owner = community.owner()?;
4167            if my_pk != owner {
4168                let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4169                let me_hex = my_pk.to_hex();
4170                let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4171                if current.contains(&me_hex) {
4172                    return Err("you are banned from this community".to_string());
4173                }
4174                let roster = crate::db::community::get_community_roles(&cid_hex)?;
4175                let owner_hex = owner.to_hex();
4176                if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
4177                    return Err("editing the banlist needs the BAN permission".to_string());
4178                }
4179                // Only the entries this edit ADDS need the per-target outrank (the fold
4180                // keeps prior authorized bans alive through its per-candidate history).
4181                for target in banned.iter().filter(|t| !current.contains(*t)) {
4182                    if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
4183                        return Err("you do not outrank a member this ban targets".to_string());
4184                    }
4185                }
4186            }
4187        }
4188        let content = super::roles::banlist_content_json(banned)?;
4189        let eid = super::derive::banlist_locator(community.id());
4190        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4191        let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
4192        // The version this publish will chain to — mirrors publish_control_edition.
4193        let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4194            Some((v, _)) => v + 1,
4195            None => 1,
4196        };
4197        publish_control_edition(transport, community, vsk::BANLIST, &eid, &content).await?;
4198        // ECHO the published list into the local cache at once. Without this, the
4199        // cache only moves on a successful control-plane fold — and a caller
4200        // composing ban steps (banlist → grant strip → refound) re-reads the STALE
4201        // list if any later step trips before the fold, so each new banlist
4202        // edition it builds ERASES every ban since the last fold. Nineteen bans in
4203        // production each overwrote their predecessor exactly this way.
4204        let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
4205        Ok(())
4206    })
4207    .await
4208}
4209
4210/// Per-community moderation-unit locks. Session-scoped: an account swap drops the
4211/// map with its session, so no cross-account carry-over and nothing to clear.
4212struct ModerationLocks;
4213fn moderation_lock(cid: &crate::community::CommunityId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
4214    let map = crate::db::current_session()
4215        .scoped::<ModerationLocks, std::sync::Mutex<std::collections::HashMap<[u8; 32], std::sync::Arc<tokio::sync::Mutex<()>>>>>();
4216    let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
4217    m.entry(cid.0).or_default().clone()
4218}
4219
4220/// Fold control + guestbook so a moderation unit composes over the freshest view
4221/// another moderator may have advanced. Best-effort: on a dead network the local
4222/// cache (kept fresh by `set_banlist`'s own echo) is still the best basis.
4223async fn converge_authority<T: Transport + ?Sized>(transport: &T, id: &crate::community::CommunityId) {
4224    if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
4225        let _ = follow_control(transport, &fresh).await;
4226        // Membership is part of the converged view: a Join that legally raced a ban
4227        // window may exist only on the relays, and our own just-published edition
4228        // doesn't echo back to trigger a follow.
4229        if let Ok(added) = sync_guestbook(transport, &fresh).await {
4230            if !added.is_empty() {
4231                crate::traits::emit_event_json(
4232                    "community_refreshed",
4233                    serde_json::json!({ "community_id": crate::simd::hex::bytes_to_hex_32(&id.0) }),
4234                );
4235            }
4236        }
4237    }
4238}
4239
4240/// One queued ban/unban intent, answered on `done` when its batch completes.
4241struct ModIntent {
4242    id: u64,
4243    targets: Vec<PublicKey>,
4244    banned: bool,
4245    done: tokio::sync::oneshot::Sender<Result<(), String>>,
4246}
4247
4248/// Per-community pending moderation intents — the coalescing queue behind
4249/// [`moderation_lock`]. Session-scoped for the same reason as the lock.
4250struct PendingModeration;
4251fn pending_moderation(cid: &crate::community::CommunityId) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>> {
4252    let _ = cid;
4253    crate::db::current_session()
4254        .scoped::<PendingModeration, std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>>()
4255}
4256
4257/// Ban or unban members as ONE moderation unit — the CORD-04 §6 three-removal
4258/// composition, once for the whole wave: one Banlist edition (instant silence),
4259/// one grant-strip pass (authority removal), and one read cut (a private
4260/// community re-founds ONCE with every target removed; a public one rotates each
4261/// reachable private channel ONCE excluding them all).
4262///
4263/// Serialized AND coalesced per community. Without the lock, concurrent bans
4264/// (the SDK spawns a handler task per message, so a spam wave is exactly this)
4265/// each build their edition from a pre-sibling banlist snapshot, and the fold
4266/// takes the HEAD's content — so the last racing head silently un-bans the
4267/// earlier target at every reader, and the refound then compacts that erasure
4268/// into the new epoch permanently.
4269///
4270/// The coalescing half: a unit's read cut holds the lock for a whole multi-
4271/// publish rotation, so serial single bans arriving meanwhile would each queue
4272/// up a rotation of their own. Instead every caller deposits an intent; whoever
4273/// next takes the lock drains ALL of them into one edition + one read cut,
4274/// applied in arrival order (ban-then-unban of one npub nets to unban). Each
4275/// intent is judged individually — a target the caller cannot outrank, or an
4276/// add past the 500-entry ceiling, fails only its own caller, never the batch.
4277pub async fn set_members_banned<T: Transport + ?Sized>(
4278    transport: &T,
4279    community_id: &crate::community::CommunityId,
4280    members: &[PublicKey],
4281    banned: bool,
4282) -> Result<(), String> {
4283    crate::db::scoped(async move {
4284        // Dedup — a doubled target must not strip or rotate twice.
4285        let mut targets: Vec<PublicKey> = Vec::with_capacity(members.len());
4286        for m in members {
4287            if !targets.contains(m) {
4288                targets.push(*m);
4289            }
4290        }
4291        if targets.is_empty() {
4292            return Ok(());
4293        }
4294        // Plain uniqueness source for intent ids — not per-account state.
4295        static INTENT_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
4296        let my_id = INTENT_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4297        let (tx, rx) = tokio::sync::oneshot::channel();
4298        {
4299            let map = pending_moderation(community_id);
4300            let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
4301            m.entry(community_id.0).or_default().push(ModIntent { id: my_id, targets, banned, done: tx });
4302        }
4303        let unit = moderation_lock(community_id);
4304        // `biased` so a verdict delivered by another leader wins over a lock we
4305        // happen to also hold — a drained intent must never run twice.
4306        tokio::select! {
4307            biased;
4308            res = rx => res.unwrap_or_else(|_| Err("moderation unit aborted before completing".to_string())),
4309            _guard = unit.lock() => {
4310                // Leader: drain EVERYTHING pending right now. Every ban/unban that
4311                // stacked while the previous unit's rotation ran coalesces here.
4312                let batch: Vec<ModIntent> = {
4313                    let map = pending_moderation(community_id);
4314                    let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
4315                    m.remove(&community_id.0).unwrap_or_default()
4316                };
4317                run_moderation_batch(transport, community_id, batch, my_id).await
4318            }
4319        }
4320    })
4321    .await
4322}
4323
4324/// Execute one drained batch of moderation intents under the held lock: judge
4325/// each intent in arrival order against the evolving list, publish the coalesced
4326/// edition, strip + read-cut the net newly-banned set once, then answer every
4327/// intent with its own verdict. Returns the LEADER's verdict.
4328async fn run_moderation_batch<T: Transport + ?Sized>(
4329    transport: &T,
4330    community_id: &crate::community::CommunityId,
4331    batch: Vec<ModIntent>,
4332    leader_id: u64,
4333) -> Result<(), String> {
4334    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4335
4336    // SYNC BEFORE COMPOSING: a banlist edition replaces the whole list on the
4337    // wire, so the mutation must start from the freshest view — another
4338    // moderator's ban this device hasn't folded yet would otherwise be
4339    // silently erased by ours.
4340    converge_authority(transport, community_id).await;
4341
4342    let original = crate::db::community::get_community_banlist(&cid_hex)?;
4343    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4344    let community0 = crate::db::community::load_community_v2(community_id)?
4345        .ok_or_else(|| "v2 community not found".to_string())?;
4346    let owner = community0.owner()?;
4347    let my_pk = me_pk()?;
4348    let my_hex = my_pk.to_hex();
4349    let owner_hex = owner.to_hex();
4350
4351    // Judge intents in arrival order against the EVOLVING list, so the last
4352    // intent naming an npub wins — exactly serial semantics, minus the churn.
4353    let mut list = original.clone();
4354    let mut pk_of: std::collections::HashMap<String, PublicKey> = std::collections::HashMap::new();
4355    let mut verdicts: std::collections::HashMap<u64, Result<(), String>> = std::collections::HashMap::new();
4356    for intent in &batch {
4357        let hexes: Vec<String> = intent.targets.iter().map(|p| p.to_hex()).collect();
4358        for (pk, hex) in intent.targets.iter().zip(&hexes) {
4359            pk_of.insert(hex.clone(), *pk);
4360        }
4361        let verdict = (|| {
4362            // Mirror of set_banlist's authority gate, per intent, so one refused
4363            // intent cannot sink its batch-mates.
4364            if my_pk != owner {
4365                if original.contains(&my_hex) {
4366                    return Err("you are banned from this community".to_string());
4367                }
4368                if !roster.is_authorized(&my_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
4369                    return Err("editing the banlist needs the BAN permission".to_string());
4370                }
4371                if intent.banned {
4372                    for t in hexes.iter().filter(|t| !list.contains(*t)) {
4373                        if !roster.can_act_on_member(&my_hex, Some(&owner_hex), t, crate::community::roles::Permissions::BAN) {
4374                            return Err("you do not outrank a member this ban targets".to_string());
4375                        }
4376                    }
4377                }
4378            }
4379            let mut next = list.clone();
4380            next.retain(|h| !hexes.contains(h));
4381            if intent.banned {
4382                next.extend(hexes.iter().cloned());
4383            }
4384            super::roles::validate_banlist(&next)?;
4385            list = next;
4386            Ok(())
4387        })();
4388        verdicts.insert(intent.id, verdict);
4389    }
4390
4391    let changed = {
4392        let mut a = original.clone();
4393        let mut b = list.clone();
4394        a.sort();
4395        b.sort();
4396        a != b
4397    };
4398    let newly_banned: Vec<PublicKey> = list
4399        .iter()
4400        .filter(|h| !original.contains(*h))
4401        .filter_map(|h| pk_of.get(h).copied())
4402        .collect();
4403
4404    // Publish the coalesced edition + strips, then the single read cut.
4405    let unit_result: Result<(), String> = async {
4406        if !changed {
4407            return Ok(());
4408        }
4409        // Rotation barrier: a sibling refound holds this lock for its whole
4410        // multi-publish rotation while the row still names the OLD root. Dropped
4411        // before `refound_community`, which re-acquires it (non-reentrant).
4412        let (community, stripped) = {
4413            let lock = super::realtime::follow_lock(community_id);
4414            let _rotation = lock.lock().await;
4415            let community = crate::db::community::load_community_v2(community_id)?
4416                .ok_or_else(|| "v2 community not found".to_string())?;
4417            set_banlist(transport, &community, &list).await?;
4418            let mut stripped: Vec<(PublicKey, Vec<String>)> = Vec::new();
4419            // Pre-strip role capture: the read-severance below judges which
4420            // private channels each member could reach, and after the strip
4421            // the fold may no longer show it.
4422            for pk in &newly_banned {
4423                let hex = pk.to_hex();
4424                let roles: Vec<String> = roster.roles_of(&hex).map(|r| r.role_id.clone()).collect();
4425                // Nothing granted = nothing to strip: a bulk ban of roleless
4426                // spammers must not publish one empty Grant edition apiece.
4427                let has_grant = roster.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
4428                if has_grant {
4429                    // Strip on the kick doctrine (skip, not refuse): readers honor a
4430                    // grant strip only from MANAGE_ROLES + outrank, and a BAN-only
4431                    // moderator still bans — the banlist silences regardless, and
4432                    // publishing a strip readers reject would only poison our own
4433                    // floor for that grant entity.
4434                    let can_strip = my_pk == owner
4435                        || roster.can_act_on_member(&my_hex, Some(&owner_hex), &hex, crate::community::roles::Permissions::MANAGE_ROLES);
4436                    if can_strip {
4437                        grant_roles(transport, &community, pk, vec![]).await?;
4438                    } else {
4439                        crate::log_warn!("[Ban] grant strip skipped (no MANAGE_ROLES over the target); the banlist still silences");
4440                    }
4441                }
4442                stripped.push((*pk, roles));
4443            }
4444            (community, stripped)
4445        };
4446        if !newly_banned.is_empty() {
4447            // CORD-05 §5 gate: a PUBLIC community — any live invite link — must NOT
4448            // refound on ban. The link refresh re-posts the bundle with the new root
4449            // behind the same URL, so the banned member re-fetches and reads on: the
4450            // rotation severs nothing and can strand foreign-link joiners on a buried
4451            // epoch. The Banlist does all the real work in Public mode.
4452            if community_is_public(transport, &community).await {
4453                crate::log_info!("[Ban] public community — banlist + grant strip, no refound (CORD-05 §5)");
4454                // CORD-06 §1 still applies per channel: only rotation severs the
4455                // private-channel reads their held keys still allow.
4456                match sever_banned_private_reads(transport, &community, &stripped).await {
4457                    Ok(0) => {}
4458                    Ok(n) => crate::log_info!("[Ban] rotated {n} private channel(s) the banned member(s) could read"),
4459                    Err(e) => crate::log_warn!("[Ban] private-channel read severance incomplete: {e}"),
4460                }
4461            } else if let Err(e) = refound_community(transport, &community, &newly_banned).await {
4462                // Best-effort escalation, NEVER the ban's verdict. The banlist
4463                // edition (instant silence at every honest reader) and the grant
4464                // strip ARE the ban; the refound's §3 coverage gate demands the
4465                // relay serve back heads published milliseconds ago, so propagation
4466                // lag makes it fail-closed — and propagating that error out of here
4467                // once left every ban half-applied AND skipped the converge below,
4468                // so each next ban rebuilt from a stale list and erased its
4469                // predecessors.
4470                crate::log_warn!("[Ban] refound deferred (banlist + grant strip landed): {e}");
4471            }
4472        }
4473        Ok(())
4474    }
4475    .await;
4476    converge_authority(transport, community_id).await;
4477
4478    // Answer every intent: its own judgment first, then the shared publish fate.
4479    let mut my_verdict = Err("leader intent missing from its own batch".to_string());
4480    for intent in batch {
4481        let final_verdict = verdicts
4482            .remove(&intent.id)
4483            .unwrap_or_else(|| Err("intent was never judged".to_string()))
4484            .and_then(|()| unit_result.clone());
4485        if intent.id == leader_id {
4486            my_verdict = final_verdict;
4487        } else {
4488            let _ = intent.done.send(final_verdict);
4489        }
4490    }
4491    my_verdict
4492}
4493
4494/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
4495/// `MANAGE_METADATA`.
4496pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
4497    ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
4498    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
4499    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
4500    publish_control_edition(transport, community, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
4501}
4502
4503/// Persist a freshly-published icon/banner onto the held row and return the fresh
4504/// row. Reloads under the community's follow lock: `save_community_v2` is a
4505/// whole-row save that prunes channels absent from the passed struct, so writing
4506/// a stale pre-upload copy would drop rows a concurrent fold just landed.
4507pub async fn persist_community_image(
4508    id: &crate::community::CommunityId,
4509    img: control::ImageRef,
4510    is_banner: bool,
4511) -> Option<CommunityV2> {
4512    let lock = super::realtime::follow_lock(id);
4513    let _guard = lock.lock().await;
4514    let mut fresh = crate::db::community::load_community_v2(id).ok()??;
4515    if is_banner {
4516        fresh.banner = Some(img);
4517    } else {
4518        fresh.icon = Some(img);
4519    }
4520    crate::db::community::save_community_v2(&fresh).ok()?;
4521    Some(fresh)
4522}
4523
4524/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
4525/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
4526pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
4527    let my_pk = me_pk()?;
4528    ensure_channel_manager(community, &my_pk)?;
4529    let old_name = community.channel(channel_id).map(|c| c.name.clone());
4530    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
4531    // mint yet — refuse the flag flip rather than publish an edition no reader can
4532    // key (members would keep posting on the root-derived plane, splitting the
4533    // channel). Private → public works (readers heal to the root derivation).
4534    if meta.private {
4535        if let Some(held) = community.channel(channel_id) {
4536            if !held.private {
4537                return Err("converting a public channel to private is not supported yet".to_string());
4538            }
4539        }
4540    }
4541    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
4542    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
4543    publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4544    // Apply locally too. The fold is the authority but runs later, so without this
4545    // an edit we just made reads back stale until some future control pass — the
4546    // rename appears to have silently failed.
4547    if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
4548        if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
4549            ch.name = meta.name.clone();
4550            ch.private = meta.private;
4551            ch.voice = meta.voice;
4552            ch.meta_custom = meta.custom.clone();
4553            ch.meta_extra = meta.extra.clone();
4554            crate::db::community::save_community_v2(&held)?;
4555        }
4556    }
4557    // Keep the companion access role's label in step with the channel it gates.
4558    if meta.private {
4559        if let Some(old) = old_name.filter(|o| *o != meta.name) {
4560            rename_channel_access_role(transport, community, channel_id, &old, &meta.name).await;
4561        }
4562    }
4563    Ok(())
4564}
4565
4566/// Rename a private channel's companion access role to follow the channel (CORD-04 §2).
4567/// Best-effort and never fatal: the channel rename has already published, and a role's
4568/// name is cosmetic — entitlement is carried by the scope, not the label.
4569///
4570/// Only renames a label still equal to the channel's OLD name, so a deliberately
4571/// customised role name survives a channel rename untouched.
4572async fn rename_channel_access_role<T: Transport + ?Sized>(
4573    transport: &T,
4574    community: &CommunityV2,
4575    channel_id: &ChannelId,
4576    old_name: &str,
4577    new_name: &str,
4578) {
4579    crate::db::scoped(async move {
4580        let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
4581            return;
4582        };
4583        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4584        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4585        // Fetched, not cached: `set_role` republishes the WHOLE role body, so a stale
4586        // cache would clobber a permission edit this client has not folded yet.
4587        let mut roster = fetch_authority(transport, community).await.roles;
4588        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4589        for r in cached.roles {
4590            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4591                roster.roles.push(r);
4592            }
4593        }
4594        // MANAGE_CHANNELS got us the rename; the role edition needs MANAGE_ROLES + outrank
4595        // of its own. Publishing one readers reject would wedge our later, legitimate role
4596        // edits behind a rejected chain, so verify before publishing rather than after.
4597        let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
4598        if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4599            return;
4600        }
4601        // Same selector `grant_channel_access` vends: the permission-less scoped role. A
4602        // per-channel moderator role sharing the scope is NOT the access list.
4603        let Some(mut role) = roster
4604            .channel_roles(&chan_hex)
4605            .into_iter()
4606            .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
4607            .cloned()
4608        else {
4609            return;
4610        };
4611        if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
4612            return;
4613        }
4614        role.name = new_name.to_string();
4615        if let Err(e) = set_role(transport, community, &role).await {
4616            crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
4617            return;
4618        }
4619        merge_local_roster(&cid_hex, Some(&role), None);
4620    })
4621    .await
4622}
4623
4624/// The local mirror of a reader-side permission fold gate: the owner, or a
4625/// roster-authorized holder of `needed` who isn't banned. Refusing BEFORE any
4626/// publish keeps an unauthorized device from advancing its own edition floor onto
4627/// a head every reader rejects (wedging its later, legitimately-authorized edits
4628/// behind a rejected chain). Fail-closed: an empty/unfolded roster collapses to
4629/// owner-only — it can over-restrict, never grant authority no one has.
4630fn ensure_folded_permission(
4631    community: &CommunityV2,
4632    me: &PublicKey,
4633    needed: u64,
4634    verb: &str,
4635) -> Result<(), String> {
4636    let owner = community.owner()?;
4637    if *me == owner {
4638        return Ok(());
4639    }
4640    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4641    let me_hex = me.to_hex();
4642    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
4643        return Err("you are banned from this community".to_string());
4644    }
4645    let roster = crate::db::community::get_community_roles(&cid_hex)?;
4646    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
4647        Ok(())
4648    } else {
4649        Err(format!("{verb} needs the {} permission", permission_label(needed)))
4650    }
4651}
4652
4653fn permission_label(bits: u64) -> &'static str {
4654    use crate::community::roles::Permissions;
4655    match bits {
4656        Permissions::MANAGE_ROLES => "MANAGE_ROLES",
4657        Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
4658        Permissions::MANAGE_METADATA => "MANAGE_METADATA",
4659        Permissions::BAN => "BAN",
4660        Permissions::CREATE_INVITE => "CREATE_INVITE",
4661        _ => "required",
4662    }
4663}
4664
4665/// [`ensure_folded_permission`] for `MANAGE_CHANNELS` (CORD-03 §2).
4666fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
4667    ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
4668}
4669
4670/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
4671/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
4672/// Plane from the `community_root` (no per-channel key), so other members fold it in on
4673/// their next control follow with nothing to distribute. Returns the new channel id.
4674/// Reader-gated by `MANAGE_CHANNELS`.
4675pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
4676    let channel_id = ChannelId(super::super::random_32());
4677    create_public_channel_with_id(transport, community, name, channel_id).await?;
4678    Ok(channel_id)
4679}
4680
4681/// [`create_public_channel`] with a CALLER-CHOSEN id — the migration-only entry point
4682/// (§migration) that reuses a v1 channel's id so chat history stitches through the flip.
4683/// Asserts the id isn't already live in a DIFFERENT held v2 community before minting.
4684pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
4685    crate::db::scoped(async move {
4686        // Serialize with the follow worker: the save below writes the WHOLE community
4687        // row from this caller's struct, so an unserialized concurrent follow adopting
4688        // a rotation would be rolled back to a stale root (a deaf community).
4689        let lock = super::realtime::follow_lock(community.id());
4690        let _guard = lock.lock().await;
4691        let my_pk = me_pk()?;
4692        ensure_channel_manager(community, &my_pk)?;
4693        assert_channel_id_free(&channel_id, community.id())?;
4694        let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
4695        control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
4696        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4697        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4698        // Add locally + persist so the creator can post immediately (peers fold it in).
4699        let mut updated = community.clone();
4700        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() });
4701        crate::db::community::save_community_v2(&updated)?;
4702        Ok(())
4703    })
4704    .await
4705}
4706
4707/// Refuse a channel id already live in a DIFFERENT held v2 community — the same
4708/// cross-community hijack the `save_community_v2` guard forecloses, checked up front so a
4709/// migration twin never adopts an id it doesn't own. A collision with a v1-owned row is
4710/// fine (that's the whole point — the flip re-parents it); only a foreign v2 owner blocks.
4711fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
4712    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4713    if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
4714        let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4715        let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
4716        if existing != mine
4717            && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
4718        {
4719            return Err("channel id is already live in another v2 community".to_string());
4720        }
4721    }
4722    Ok(())
4723}
4724
4725/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
4726/// random key at channel-epoch 1, mint a companion channel-scoped Role that is
4727/// the channel's access list (CORD-04 §2), deliver the key to the entitled over
4728/// the rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`).
4729/// Epoch 0 is the root generation ("the first privatisation is epoch 1"), so the
4730/// delivery commits its continuity to `(0, community_root)` — verifiable by every
4731/// member and bound to THIS community's root. The key ships BEFORE the
4732/// announcement: an aborted attempt leaves only an unannounced crate (invisible),
4733/// and a retry mints a fresh id, so there is no same-coordinate double-mint to
4734/// fork on. Live public links are refreshed; they carry no private key, so this
4735/// only re-states the public set.
4736pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
4737    let channel_id = ChannelId(super::super::random_32());
4738    create_private_channel_with_id(transport, community, name, channel_id).await?;
4739    Ok(channel_id)
4740}
4741
4742/// The companion Role minted alongside a Private channel — the channel's access
4743/// list (CORD-04 §2 `scope: {"kind":"channel"}`). Same name as the channel, and
4744/// **no permission bits**: it confers read access, which is key possession, never
4745/// authority. Position sits below every management role for the same reason.
4746pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
4747    use crate::community::roles::{Permissions, Role, RoleScope};
4748    Role {
4749        role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
4750        name: name.to_string(),
4751        position: u32::MAX - 1,
4752        permissions: Permissions::empty(),
4753        scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
4754        color: 0,
4755    }
4756}
4757
4758/// [`create_private_channel`] with a CALLER-CHOSEN id — the migration-only entry point
4759/// (§migration) reusing a v1 private channel's id so history stitches through the flip.
4760pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
4761    crate::db::scoped(async move {
4762        // Serialize with the follow worker across the whole fetch→publish→save span
4763        // (the memberlist fetch is seconds long; an unserialized follow adopting a
4764        // rotation meanwhile would be rolled back by the whole-row save below).
4765        let lock = super::realtime::follow_lock(community.id());
4766        let _guard = lock.lock().await;
4767        let signer = crate::signer::active_signer()?;
4768        let my_pk = me_pk()?;
4769        ensure_channel_manager(community, &my_pk)?;
4770        assert_channel_id_free(&channel_id, community.id())?;
4771        let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
4772        control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
4773        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4774
4775        let channel_key = super::super::random_32();
4776        let epoch = Epoch(1);
4777
4778        // The channel's access list: a companion channel-scoped Role (CORD-04 §2),
4779        // granted to me so the creator is entitled from the first edition.
4780        let access_role = channel_access_role(&channel_id, name);
4781        let access_role_ids = vec![access_role.role_id.clone()];
4782
4783        // Recipients are the ENTITLED, not the memberlist: CORD-03's private channel
4784        // is "readable only by granted role-holders". At create that is me (plus the
4785        // owner, who is always entitled) — everyone else keys up when granted.
4786        let owner = community.owner()?;
4787        let mut recipients = vec![my_pk];
4788        if owner != my_pk {
4789            recipients.push(owner);
4790        }
4791        let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
4792        let mut blobs = Vec::with_capacity(recipients.len());
4793        for r in &recipients {
4794            blobs.push(
4795                rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
4796                    .await
4797                    .map_err(|e| e.to_string())?,
4798            );
4799        }
4800        let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
4801        let at_secs = now_ms() / 1000;
4802        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())
4803            .await
4804            .map_err(|e| e.to_string())?;
4805        for c in &chunks {
4806            transport.publish_durable(c, &community.relays).await?;
4807        }
4808        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4809        // Publish the access list AFTER the channel exists, so a peer folding the
4810        // Role always resolves the channel it scopes to. A failure here leaves a
4811        // channel only its creator can read — recoverable by re-granting, never a
4812        // leak.
4813        set_role(transport, community, &access_role).await?;
4814        // Judge staff-ness against a roster that HOLDS the role just minted above —
4815        // no fold has read it back yet, and the access role is permission-less, so
4816        // resolving it locally both skips a round trip and gets the right answer.
4817        let mut roster_with_access = crate::db::community::get_community_roles(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap_or_default();
4818        if roster_with_access.role(&access_role.role_id).is_none() {
4819            roster_with_access.roles.push(access_role.clone());
4820        }
4821        grant_roles_with_roster(transport, community, &my_pk, access_role_ids.clone(), &roster_with_access).await?;
4822        // The fold is the authority but runs later; without this the creator is not
4823        // yet entitled to their own channel and the next grant finds no access role.
4824        merge_local_roster(
4825            &crate::simd::hex::bytes_to_hex_32(&community.id().0),
4826            Some(&access_role),
4827            Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
4828        );
4829        // A leave/delete raced the create: saving would resurrect the community row.
4830        if crate::db::community::community_protocol(community.id())?.is_none() {
4831            return Err("community removed during channel create".to_string());
4832        }
4833        let mut updated = community.clone();
4834        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() });
4835        crate::db::community::save_community_v2(&updated)?;
4836        // Archive the epoch-1 key so this channel's history stays readable across its
4837        // future rotations (CORD-03 §3).
4838        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4839        crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
4840        // Re-state live links. They carry no private key (CORD-05 §2 — a link's
4841        // audience holds no Role), so this only refreshes the public set.
4842        let _ = refresh_public_links(transport, &updated).await;
4843        Ok(())
4844    })
4845    .await
4846}
4847
4848/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
4849// ── Receiving a key vend (CORD-03 "delivered on grant") ──────────────────────
4850
4851/// What a client should do with a vended Private-Channel key right now.
4852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4853pub enum VendVerdict {
4854    /// Every rule passed — adopt the key.
4855    Accept,
4856    /// Cannot judge YET: our fold lags the grant it delivers. Park quietly and
4857    /// re-judge after the next control follow. NOT an anomaly — a lagging fold
4858    /// is the normal case for a vend that races its own Grant.
4859    Park(&'static str),
4860    /// Judged invalid against evidence that cannot become true later. Alarm-worthy.
4861    Refuse(&'static str),
4862}
4863
4864/// Judge a vended Private-Channel key against our OWN folded state.
4865///
4866/// The Grant is the authority half and rides the owner-rooted control plane, so
4867/// it cannot be forged; the vend is only delivery. Acceptance therefore rests
4868/// entirely on what our own fold proves — a bundle can never introduce a channel
4869/// our control plane doesn't define, which is what closes the hidden-channel
4870/// injection class.
4871///
4872/// `community` must already be the held (self-certified) community: the caller
4873/// resolves it by `community_id`, so a bundle naming a community we're not in is
4874/// never judged here at all.
4875pub fn judge_channel_key_vend(
4876    community: &CommunityV2,
4877    roster: &crate::community::roles::CommunityRoles,
4878    channel_id: &ChannelId,
4879    epoch: Epoch,
4880    sender_hex: &str,
4881) -> VendVerdict {
4882    let me = match me_pk() {
4883        Ok(pk) => pk.to_hex(),
4884        Err(_) => return VendVerdict::Park("no active identity"),
4885    };
4886    let owner_hex = match community.owner() {
4887        Ok(o) => o.to_hex(),
4888        Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
4889    };
4890    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4891
4892    // (2) The channel must exist in OUR fold, and be private there. The bundle's
4893    // own claims are ignored: a vend may deliver a key, never define a channel.
4894    let Some(ch) = community.channel(channel_id) else {
4895        return VendVerdict::Park("channel not in our fold yet");
4896    };
4897    if !ch.private {
4898        // Never heals: our owner-rooted fold says this channel is public, so a
4899        // "private key" for it is a spoof, not a lagging view.
4900        return VendVerdict::Refuse("vend names a channel our fold says is public");
4901    }
4902
4903    // (5) Epoch sanity, BOTH directions. Below is superseded by the rotation that
4904    // produced our copy. Above matters more: the channel head is monotonic, so a
4905    // wildly-ahead epoch is not merely wrong, it is PERMANENT — every genuine
4906    // rotation afterwards lands at `head + 1`, is refused as stale, and the
4907    // channel dies for us with no heal path at all (not a rekey, not a re-grant,
4908    // not a refound). Rotations advance one epoch at a time, so a lead this large
4909    // is never a delivery we could place.
4910    if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
4911        return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
4912    }
4913    if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
4914        return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
4915    }
4916
4917    // (3) OUR fold must show US granted a role scoped to this channel. This is
4918    // the rule that kills the spoof class: an attacker cannot forge the Grant,
4919    // so they cannot make us accept a key for a channel we were never granted.
4920    if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
4921        return VendVerdict::Park("our grant for this channel has not folded yet");
4922    }
4923
4924    // (4) The vendor must be entitled too — they hold the real key, so a wrong
4925    // key from them costs isolation, never confidentiality.
4926    if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
4927        return VendVerdict::Park("vendor's entitlement has not folded yet");
4928    }
4929
4930    VendVerdict::Accept
4931}
4932
4933/// How long an unprovable parked vend is kept. Deliberately long: the fallback
4934/// heal is the channel's next rotation, which may never come.
4935const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
4936
4937/// How far above our channel head a vend may claim to be. Generous — a keyless
4938/// cursor can lag a busy channel by many rotations — but bounded, because the
4939/// head is monotonic and an over-advance can never be walked back.
4940const MAX_VEND_EPOCH_LEAD: u64 = 1024;
4941
4942/// Re-judge every parked key vend for this community and adopt the ones that now
4943/// pass. Runs after a control follow (the fold moved, so verdicts can change) and
4944/// on the boot sweep.
4945///
4946/// Returns the channels newly keyed up.
4947pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &std::sync::Arc<crate::db::Session>) -> Vec<ChannelId> {
4948    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4949    let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
4950        Ok(p) if !p.is_empty() => p,
4951        _ => return Vec::new(),
4952    };
4953    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4954    let mut adopted = Vec::new();
4955    let now = now_ms() / 1000;
4956    for p in parked {
4957        // Several candidates may name one channel (parking is open to any sender,
4958        // so a stranger can never suppress the entitled vendor's key by holding a
4959        // slot). Once one is seated the rest are moot.
4960        if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
4961            let _ = crate::db::community::drop_pending_channel_key(p.id);
4962            continue;
4963        }
4964        // A vend we were never able to prove is not kept forever: an admin who
4965        // adds then immediately removes someone leaves a row nothing will ever
4966        // discharge. Generous by design — the alternative heal (the channel's
4967        // next rotation) can be arbitrarily far away, so this is hygiene, not a
4968        // deadline.
4969        if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
4970            let _ = crate::db::community::drop_pending_channel_key(p.id);
4971            continue;
4972        }
4973        let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
4974            let _ = crate::db::community::drop_pending_channel_key(p.id);
4975            continue;
4976        };
4977        let channel_id = ChannelId(id_bytes);
4978        match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
4979            VendVerdict::Accept => {
4980                if !session.is_live() {
4981                    return adopted;
4982                }
4983                // First delivery vs rotation. A keyless channel must bypass the
4984                // monotonic guard: it sits at the epoch-0 cursor, and a peer that
4985                // mints born-private channels at epoch 0 vends that same epoch, so
4986                // `new > current` would refuse the only key on offer.
4987                let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
4988                let seated = if keyless {
4989                    crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
4990                } else {
4991                    crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4992                };
4993                if let Err(e) = seated {
4994                    crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4995                    continue;
4996                }
4997                // The key landed — every other candidate for this channel is moot.
4998                let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4999                adopted.push(channel_id);
5000            }
5001            VendVerdict::Refuse(why) => {
5002                crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
5003                // Only THIS candidate — a sibling may still be the genuine vend.
5004                let _ = crate::db::community::drop_pending_channel_key(p.id);
5005            }
5006            // Quiet by design: the fold simply hasn't caught up.
5007            VendVerdict::Park(_) => {}
5008        }
5009    }
5010    adopted
5011}
5012
5013/// Grant `member` read access to a Private channel (CORD-03 "delivered on
5014/// grant"): publish a Grant adding the channel's access role, then vend the key
5015/// as a CORD-05 §6 Direct Invite whose bundle carries exactly the channels they
5016/// are now entitled to.
5017///
5018/// The Grant is the authority half and rides the owner-rooted control plane, so
5019/// it cannot be forged; the vend is only delivery. A recipient accepts the key
5020/// solely on the strength of their OWN fold showing this grant — the bundle can
5021/// never introduce a channel their control plane doesn't define.
5022pub async fn grant_channel_access<T: Transport + ?Sized>(
5023    transport: &T,
5024    community: &CommunityV2,
5025    channel_id: &ChannelId,
5026    member: &PublicKey,
5027) -> Result<(), String> {
5028    crate::db::scoped(async move {
5029        let my_pk = me_pk()?;
5030        let ch = community.channel(channel_id).ok_or("unknown channel")?;
5031        if !ch.private {
5032            return Err("channel is public — every member already reads it".to_string());
5033        }
5034        if ch.key.is_none() {
5035            return Err("we hold no key for this channel, so we cannot vend it".to_string());
5036        }
5037        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5038        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5039        let owner_hex = community.owner()?.to_hex();
5040        // A Grant REPLACES the member's role set, so the union it is built from must
5041        // be CURRENT: a stale local roster would silently strip every role this
5042        // client hasn't folded yet. Fetch the authority fresh rather than trusting
5043        // the cache, and merge the local view on top so a role we just published
5044        // ourselves (which the plane has but no fold has read back) survives too.
5045        let view = fetch_authority(transport, community).await;
5046        let mut roster = view.roles.clone();
5047        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5048        for r in cached.roles {
5049            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5050                roster.roles.push(r);
5051            }
5052        }
5053        for g in cached.grants {
5054            if !roster.grants.iter().any(|x| x.member == g.member) {
5055                roster.grants.push(g);
5056            }
5057        }
5058        let my_hex = my_pk.to_hex();
5059        let member_hex = member.to_hex();
5060        // Reader-gated by MANAGE_ROLES, like any Grant; narrowed to this channel so
5061        // a channel-scoped manager can run its own access list.
5062        if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5063            return Err("not authorized to manage this channel's access".to_string());
5064        }
5065        // Rank as well as the bit: the fold drops a Grant aimed at a peer or superior
5066        // (CORD-04 §2), so publishing one vends a key against a role set no reader
5067        // will honour. The owner is the exception — never a valid rank target, but a
5068        // legitimate recipient of a channel key an admin minted.
5069        if member_hex != owner_hex {
5070            if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5071                return Err("you do not outrank this member".to_string());
5072            }
5073            // A Grant replaces whole — a floored-but-unserved head would publish a
5074            // wipe. The owner holds no Grant (their authority is implicit), so there
5075            // is no head to insist on and nothing a first edition could erase.
5076            require_grant_head(community, &view, &member_hex)?;
5077        }
5078        // The channel's roles are ordered by AUTHORITY, so `.first()` is the most
5079        // privileged — granting read access must never hand out a per-channel
5080        // moderator role that happens to share the scope. Pick the permission-less
5081        // one: conferring read access is exactly what carries no authority.
5082        let role_id = roster
5083            .channel_roles(&chan_hex)
5084            .into_iter()
5085            .find(|r| r.permissions == crate::community::roles::Permissions::empty())
5086            .map(|r| r.role_id.clone())
5087            .ok_or("channel has no permission-less access role to grant")?;
5088
5089        let mut role_ids: Vec<String> = roster.roles_of(&member_hex).map(|r| r.role_id.clone()).collect();
5090        if !role_ids.contains(&role_id) {
5091            role_ids.push(role_id.clone());
5092        }
5093        // The access role is permission-less (never staff-making), but a KEPT role
5094        // can be — the fetched roster judges (CORD-04 §3).
5095        grant_roles_with_roster(transport, community, member, role_ids.clone(), &roster).await?;
5096        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
5097        // Settle the vend against the Grant we JUST published — the fold lags it.
5098        let bundle = bundle_of_with_overlay(
5099            community,
5100            BundleAudience::Member(*member),
5101            Some(my_pk),
5102            None,
5103            None,
5104            std::slice::from_ref(&role_id),
5105            &[],
5106        );
5107        let signer = crate::signer::active_signer()?;
5108        let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
5109        transport.publish(&wrap, &community.relays).await?;
5110        Ok(())
5111    })
5112    .await
5113}
5114
5115/// Revoke `member`'s read access to a Private channel (CORD-03 "rekeyed on
5116/// removal"): drop the channel's access role from their Grant, then rotate the
5117/// channel to its next epoch delivering the fresh key to everyone still
5118/// entitled (CORD-06). The revoked member keeps whatever history they already
5119/// read — a rekey protects the future, never the past.
5120pub async fn revoke_channel_access<T: Transport + ?Sized>(
5121    transport: &T,
5122    community: &CommunityV2,
5123    channel_id: &ChannelId,
5124    member: &PublicKey,
5125) -> Result<(), String> {
5126    crate::db::scoped(async move {
5127        let my_pk = me_pk()?;
5128        let ch = community.channel(channel_id).ok_or("unknown channel")?;
5129        if !ch.private {
5130            return Err("channel is public — there is no access to revoke".to_string());
5131        }
5132        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5133        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5134        let owner_hex = community.owner()?.to_hex();
5135        // Same replace-not-merge hazard as the grant: the retained set must be built
5136        // from a CURRENT roster or this revoke strips roles we simply hadn't folded.
5137        let view = fetch_authority(transport, community).await;
5138        let mut roster = view.roles.clone();
5139        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5140        for r in cached.roles {
5141            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5142                roster.roles.push(r);
5143            }
5144        }
5145        for g in cached.grants {
5146            if !roster.grants.iter().any(|x| x.member == g.member) {
5147                roster.grants.push(g);
5148            }
5149        }
5150        let my_hex = my_pk.to_hex();
5151        let member_hex = member.to_hex();
5152        if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5153            return Err("not authorized to manage this channel's access".to_string());
5154        }
5155        if *member == community.owner()? {
5156            return Err("the owner is supreme and cannot be removed".to_string());
5157        }
5158        // Rank as well as the bit, and it matters more here than on the grant side:
5159        // readers drop the Grant, but the channel rotation below is gated on the
5160        // citation alone, so an unauthorized revoke still severs its target.
5161        if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5162            return Err("you do not outrank this member".to_string());
5163        }
5164        // A Grant replaces whole — a floored-but-unserved head would publish a wipe.
5165        require_grant_head(community, &view, &member_hex)?;
5166        let access_ids = roster.channel_role_ids(&chan_hex);
5167        // Without the access list this revoke is a no-op that still ROTATES, and the
5168        // rotation's recipient filter would match nobody — cutting off every
5169        // legitimately entitled member. Refuse rather than mass-evict.
5170        if access_ids.is_empty() {
5171            return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
5172        }
5173        let remaining: Vec<String> = roster
5174            .roles_of(&member_hex)
5175            .map(|r| r.role_id.clone())
5176            .filter(|id| !access_ids.contains(id))
5177            .collect();
5178        grant_roles_with_roster(transport, community, member, remaining.clone(), &roster).await?;
5179        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
5180        // Rotate so the removal actually severs them (CORD-06 §1). The revoked
5181        // member is excluded from the recipient set by the overlay, since the fold
5182        // has not yet caught the Grant we just published.
5183        rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, std::slice::from_ref(member)).await
5184    })
5185    .await
5186}
5187
5188/// Rotate one Private channel to its next epoch, delivering the fresh key to
5189/// everyone entitled EXCEPT `removed` (CORD-06 §1 single-channel rekey).
5190///
5191/// `roster` must be the caller's CURRENT view (fetched, not the local cache):
5192/// the recipient set is built from it, so a cached roster silently drops every
5193/// member granted since this client last folded — they keep a dead key with no
5194/// heal path. `access_ids` is that roster's access-role set for this channel;
5195/// `removed` is excluded explicitly, since the revoking Grant was published
5196/// moments ago and no fold has caught it. A batch ban excludes its whole wave
5197/// in ONE rotation — that is why this takes a slice.
5198async fn rekey_channel_excluding<T: Transport + ?Sized>(
5199    transport: &T,
5200    community: &CommunityV2,
5201    channel_id: &ChannelId,
5202    roster: &crate::community::roles::CommunityRoles,
5203    access_ids: &[String],
5204    removed: &[PublicKey],
5205) -> Result<(), String> {
5206    crate::db::scoped(async move {
5207        // Whole-row save below — serialize with the follow worker (see create_*_channel).
5208        let lock = super::realtime::follow_lock(community.id());
5209        let _guard = lock.lock().await;
5210        let signer = crate::signer::active_signer()?;
5211        let my_pk = me_pk()?;
5212        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5213        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5214        let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
5215        let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
5216        let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
5217        let owner = community.owner()?;
5218        let owner_hex = owner.to_hex();
5219
5220        // Everyone still entitled: the owner (always), me (the rotator must be able
5221        // to read what it rekeys), and every member the roster shows holding an
5222        // access role — minus the removals.
5223        let removed_hexes: std::collections::HashSet<String> = removed.iter().map(|p| p.to_hex()).collect();
5224        let mut recipients: Vec<PublicKey> = vec![my_pk];
5225        if owner != my_pk {
5226            recipients.push(owner);
5227        }
5228        for g in &roster.grants {
5229            if removed_hexes.contains(&g.member) || g.member == owner_hex {
5230                continue;
5231            }
5232            if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
5233                continue;
5234            }
5235            if let Ok(pk) = PublicKey::parse(&g.member) {
5236                if !recipients.contains(&pk) {
5237                    recipients.push(pk);
5238                }
5239            }
5240        }
5241        // Mint-or-reuse keyed by (channel, next epoch) so a retry after a partial
5242        // publish re-uses the same key instead of forking the epoch.
5243        let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
5244        let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
5245        let mut blobs = Vec::with_capacity(recipients.len());
5246        for r in &recipients {
5247            blobs.push(
5248                rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
5249                    .await
5250                    .map_err(|e| e.to_string())?,
5251            );
5252        }
5253        let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
5254        let at_secs = now_ms() / 1000;
5255        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())
5256            .await
5257            .map_err(|e| e.to_string())?;
5258        for c in &chunks {
5259            transport.publish_durable(c, &community.relays).await?;
5260        }
5261        if crate::db::community::community_protocol(community.id())?.is_none() {
5262            return Err("community removed during channel rekey".to_string());
5263        }
5264        // Adopt locally + archive, so our own history reads across the rotation.
5265        crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
5266        crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
5267
5268        // §7 Rotator duty: reseal the Pin List under the NEW key. Without this,
5269        // members who join at this epoch hold no old key and the channel's pins
5270        // read as sealed-dark for them forever. The rotator is uniquely placed:
5271        // it provably reads the old seal (it held the old key) and mints the new
5272        // one. Best-effort — a failed reseal never fails the rotation, and any
5273        // curator's next edit heals the same way.
5274        {
5275            let mut rotated = community.clone();
5276            if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
5277                c.key = Some(new_key);
5278                c.epoch = new_epoch;
5279            }
5280            match read_channel_pins(&rotated, channel_id) {
5281                Ok(read) if !read.sealed && !read.pins.is_empty() => {
5282                    let entries: Vec<super::pins::PinEntry> =
5283                        read.pins.iter().map(|p| p.entry.clone()).collect();
5284                    if let Some(ch2) = rotated.channel(channel_id).cloned() {
5285                        if let Err(e) = publish_pin_list(transport, &rotated, &ch2, &entries).await {
5286                            crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
5287                        } else {
5288                            crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
5289                        }
5290                    }
5291                }
5292                Ok(read) if read.sealed => {
5293                    crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
5294                }
5295                _ => {}
5296            }
5297        }
5298        Ok(())
5299    })
5300    .await
5301}
5302
5303/// Rotate every private channel a just-banned member could read (CORD-06 §1 applied
5304/// per channel). A Public-community Ban skips the Refounding (CORD-05 §5), but the
5305/// banlist and grant strip alone leave the member holding each private channel's
5306/// CURRENT epoch key — rotation is the only read severance.
5307///
5308/// Takes the whole banned wave: a channel several targets could read rotates ONCE,
5309/// excluding them all. Per-target rotation would mint N epochs for one ban wave and
5310/// churn every legitimate reader N times.
5311///
5312/// Each target carries its role set as captured before the strip: the fold may or
5313/// may not have caught the strip yet, and the `with` overlay makes the entitlement
5314/// judgment independent of that timing.
5315///
5316/// Offer-side mirror of the reader's `channel_rotator_ok`: a channel rotation is
5317/// honored only from `MANAGE_CHANNELS` holders, so a BAN-only moderator must not
5318/// publish one — locally adopting an epoch every reader rejects forks the channel.
5319///
5320/// Best-effort per channel (one failure must not leave the others unrotated);
5321/// returns how many channels rotated, or the joined failures.
5322pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
5323    transport: &T,
5324    community: &CommunityV2,
5325    targets: &[(PublicKey, Vec<String>)],
5326) -> Result<usize, String> {
5327    crate::db::scoped(async move {
5328        let my_pk = me_pk()?;
5329        ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
5330        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5331        // Recipients need the CURRENT entitlement view — fetched, then merged over the
5332        // cache so entitlements we published ourselves survive a lagging fold (the same
5333        // two-strand merge as `revoke_channel_access`).
5334        let mut roster = fetch_authority(transport, community).await.roles;
5335        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5336        for r in cached.roles {
5337            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5338                roster.roles.push(r);
5339            }
5340        }
5341        for g in cached.grants {
5342            if !roster.grants.iter().any(|x| x.member == g.member) {
5343                roster.grants.push(g);
5344            }
5345        }
5346        let owner_hex = community.owner().ok().map(|o| o.to_hex());
5347        let mut rotated = 0usize;
5348        let mut failures: Vec<String> = Vec::new();
5349        for ch in &community.channels {
5350            if !ch.private || ch.key.is_none() {
5351                continue;
5352            }
5353            let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
5354            // Everyone in the wave who could read this channel, cut in ONE rotation.
5355            let excluded: Vec<PublicKey> = targets
5356                .iter()
5357                .filter(|(pk, stripped)| roster.is_entitled(owner_hex.as_deref(), &pk.to_hex(), &chan_hex, stripped, &[]))
5358                .map(|(pk, _)| *pk)
5359                .collect();
5360            if excluded.is_empty() {
5361                continue;
5362            }
5363            let access_ids = roster.channel_role_ids(&chan_hex);
5364            // Entitlement without an access-role set can't happen for a non-owner, and
5365            // an empty set would make the rotation's recipient filter mass-evict.
5366            if access_ids.is_empty() {
5367                continue;
5368            }
5369            // Reload per iteration: each rotation advances the held document, and a
5370            // stale struct would mint a colliding channel epoch on the next pass.
5371            let held = match crate::db::community::load_community_v2(community.id()) {
5372                Ok(Some(c)) => c,
5373                _ => return Err("community gone during ban severance".to_string()),
5374            };
5375            match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, &excluded).await {
5376                Ok(()) => rotated += 1,
5377                Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
5378            }
5379        }
5380        if failures.is_empty() {
5381            Ok(rotated)
5382        } else {
5383            Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
5384        }
5385    })
5386    .await
5387}
5388
5389/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
5390/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
5391pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
5392    crate::db::scoped(async move {
5393        // Whole-row save below — serialize with the follow worker (see create_*_channel).
5394        let lock = super::realtime::follow_lock(community.id());
5395        let _guard = lock.lock().await;
5396        let my_pk = me_pk()?;
5397        ensure_channel_manager(community, &my_pk)?;
5398        // The tombstone carries the FULL held document (deleted flag set): a strict
5399        // reader treats an edition as the entity, so even a deletion must not strip
5400        // fields it didn't touch (CORD-02 §6).
5401        let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
5402            name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
5403        });
5404        meta.deleted = Some(true);
5405        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
5406        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5407        let mut updated = community.clone();
5408        updated.channels.retain(|c| c.id.0 != channel_id.0);
5409        crate::db::community::save_community_v2(&updated)?;
5410        Ok(())
5411    })
5412    .await
5413}
5414
5415// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
5416
5417/// Re-fold this community's Control Plane and apply the current metadata +
5418/// **public** channel set to the held community, persisting any change. Called
5419/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
5420/// edited description) so a long-running bot tracks the community mid-session
5421/// instead of freezing at its join-time view.
5422///
5423/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
5424/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
5425/// edition is eligible only if its signer CURRENTLY holds the entity's management
5426/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
5427/// a demoted one's drop. The owner is supreme, proven by the self-certifying
5428/// community_id (no network trust).
5429///
5430/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
5431/// delivered over the rekey plane (or an invite bundle), never derivable from a
5432/// control edition alone. A new Private channel therefore surfaces only once
5433/// [`follow_rekeys`] delivers its key. Public channels derive from the
5434/// community_root, so they fold in directly.
5435///
5436/// Returns the updated community iff something changed (so the caller can skip a
5437/// redundant re-subscribe + refresh notification).
5438pub async fn follow_control<T: Transport + ?Sized>(
5439    transport: &T,
5440    community: &CommunityV2,
5441) -> Result<Option<CommunityV2>, String> {
5442    crate::db::scoped(async move {
5443        community.owner()?; // fail fast if the community is somehow unproven.
5444        let control = control::ControlPlane::of(community);
5445        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5446
5447        // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
5448        // under a prior epoch is excluded, so that entity auto-bootstraps after a
5449        // Refounding (Armada accepts a compacted head across a dangling prev — matched).
5450        // A read error FAILS CLOSED: an empty map would silently re-open the rollback
5451        // window the floor exists to shut.
5452        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
5453            .into_iter()
5454            .filter(|(_, f)| f.0 == community.root_epoch.0)
5455            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
5456            .collect();
5457
5458        // Newest window first; page OLDER only while a tracking entity is gapped (its
5459        // floor link evicted from the window — H1/M8 refetch), bounded like the join
5460        // verifier. A withholding relay still converges to fail-closed after the cap.
5461        let mut editions: Vec<ParsedEdition> = Vec::new();
5462        let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
5463        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5464        let mut oldest: Option<u64> = None;
5465        let mut until: Option<u64> = None;
5466        let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
5467        let mut authority = AuthoritySet::owner_only();
5468        // Whether this round gave up with editions still unread. The follow is
5469        // procedural by design — process what arrives, converge with everyone else —
5470        // so a short read never blocks reading, writing or epoch adoption. It only
5471        // withholds the ROSTER cache below: caching a partial authority as this
5472        // device's baseline is the one step that outlives the round.
5473        let mut truncated = true;
5474        for _ in 0..FOLLOW_MAX_PAGES {
5475            // Quorum, DECLARED (the until→Full transport floor is gone): these
5476            // control reads tolerate a partial union — their fold semantics are
5477            // fail-safe on gaps (seeded banlists, withheld roster cache).
5478            let query = Query {
5479                kinds: vec![stream::KIND_WRAP],
5480                authors: vec![control.pk_hex()],
5481                until,
5482                limit: Some(FOLLOW_PAGE),
5483                evidence: crate::community::transport::Evidence::Quorum,
5484                ..Default::default()
5485            };
5486            let wraps = transport.fetch(&query, &community.relays).await?;
5487            // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
5488            // at a page boundary); the wrap-id dedup makes re-served boundary events
5489            // free, and a page with nothing new means the relay is exhausted.
5490            let mut fresh = 0usize;
5491            for w in &wraps {
5492                if !seen_wraps.insert(w.id) {
5493                    continue;
5494                }
5495                fresh += 1;
5496                let at = w.created_at.as_secs();
5497                if oldest.is_none_or(|o| at < o) {
5498                    oldest = Some(at);
5499                }
5500                // Open + seal-verify every edition; authority is resolved by the roster
5501                // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
5502                if let Ok((ed, _)) = control.open(w) {
5503                    if seen.insert(ed.inner_id) {
5504                        editions.push(ed);
5505                    }
5506                }
5507            }
5508            // Roster first (roles/grants/banlist → authorized set), then the authority-
5509            // gated metadata/channel fold over the same edition set.
5510            authority = fold_authority(community, &editions, &floors);
5511            fold = apply_control_fold(community, &editions, &floors, &authority);
5512            if !(fold.gapped || authority.gapped) {
5513                truncated = false; // nothing is gapped: this view is coherent
5514                break;
5515            }
5516            if fresh == 0 {
5517                // A FULL page with nothing new is a same-second wall no `until` steps
5518                // past, so older editions stay unreachable; a short page is the end
5519                // of the plane, and a gap in THAT is the relay withholding, not us
5520                // giving up early.
5521                truncated = wraps.len() >= FOLLOW_PAGE;
5522                break;
5523            }
5524            until = oldest;
5525        }
5526
5527        // The fetches straddled awaits; a swap since the guard was captured must not
5528        // write account A's control state into B.
5529        // A leave/delete raced this follow: writing now would resurrect the community
5530        // row and orphan floor rows past delete_community's wipe.
5531        if crate::db::community::community_protocol(community.id())?.is_none() {
5532            return Ok(None);
5533        }
5534        // Persist advanced floors BEFORE the state save (a failed floor write must not
5535        // let saved state outrun its floor), stamping the epoch this fold ran under —
5536        // not the row's write-time value, which a concurrent re-founding can bump. Both
5537        // the metadata/channel heads and the roster/banlist heads advance their floors;
5538        // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
5539        for h in fold.heads.iter().chain(authority.heads.iter()) {
5540            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)?;
5541            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)?;
5542        }
5543        // Adopt the staff write key a promotion delivered inside MY OWN Grant
5544        // (CORD-04 §3): decrypt the control_wrap under the granter↔me pairwise key
5545        // and record the secret. Verified fail-closed inside — the epoch must be
5546        // the one I'm on and the secret must derive to exactly the control_pk I
5547        // hold — so a stale wrap (compaction re-wraps a Grant head verbatim across
5548        // Refoundings) or a forged one is dropped, never adopted.
5549        let adopted_control_root = adopt_my_control_wrap(community, &editions).await;
5550        // The adopt can await a remote signer (bunker NIP-44 round-trip) — re-validate
5551        // before the per-account writes below.
5552        // Persist the authorized banlist content (retained/withholding folds carry None,
5553        // so the stored banlist is left intact — an anti-roster never silently un-bans).
5554        let mut authority_changed = false;
5555        // Ban marks MERGE (never replace): they must outlive both the ban and this window,
5556        // so a later un-ban can't resurrect a pre-ban Join. Persisted even when the banlist
5557        // itself was retained — the history is what the suppression reads.
5558        let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
5559        if let Some((banned, version)) = &authority.banlist_persist {
5560            let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5561            crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
5562            let mut after = banned.clone();
5563            before.sort();
5564            after.sort();
5565            authority_changed |= before != after;
5566        }
5567        // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
5568        // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
5569        // fetch path: only an aggregate built from roster editions at least as new as
5570        // the stored one may replace it — a withholding relay serving NO roster
5571        // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
5572        // and that must RETAIN the stored roster, never wipe standing.
5573        let newest_roster_at: i64 = editions
5574            .iter()
5575            .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
5576            .map(|e| e.created_at as i64)
5577            .max()
5578            .unwrap_or(0);
5579        // Completeness gate: the `gapped` flag only covers entities present in the window.
5580        // A role/grant floored on this device but with ZERO editions fetched (aged out of
5581        // the paging reach) folds absent yet raises no gap — persisting would silently drop
5582        // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
5583        // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
5584        let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5585        let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
5586        let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
5587            && stored.grants.iter().all(|g| {
5588                crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
5589                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
5590                    !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
5591                })
5592            });
5593        // `truncated` covers the case the other three can't: a COLD device (no floors,
5594        // no stored roster) folding under a plane a member has inflated past the pager.
5595        // `stored_complete` is trivially true with nothing stored, so without this the
5596        // first sync would cache a partial authority as its own baseline.
5597        if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
5598            authority_changed |= stored != authority.roles;
5599            crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
5600        }
5601        // Cache the folded invite Registry so Public/Private stays a sync LOCAL read
5602        // (v1 parity — `invite_registry` is the column every caller reads). Gated like
5603        // the roster: a truncated or gapped window folds an empty registry out of mere
5604        // absence, and persisting that under-states Public — the unsafe direction, since
5605        // it leaves a live link open behind a ban.
5606        if !truncated && !authority.gapped && !fold.gapped {
5607            if let Ok(owner) = community.owner() {
5608                let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
5609                let live = flatten_link_sets(&sets);
5610                let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
5611                before.sort();
5612                if before != live {
5613                    crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
5614                    authority_changed = true;
5615                }
5616                // The per-creator split drives "X has N active invite links" and the
5617                // first-link-flips-Public confirm; it lives in its own table.
5618                crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
5619            }
5620        }
5621        // Folded Pin List heads (CORD-04 §7): raw content per channel. The write
5622        // itself is monotonic on version (atomic in the statement), so a stale
5623        // window racing a publish echo can never regress a newer held head.
5624        for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
5625            match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
5626                Ok(true) => {
5627                    crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
5628                    crate::emit_event(
5629                        "community_pins_updated",
5630                        &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
5631                    );
5632                    note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
5633                }
5634                Ok(false) => {}
5635                Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
5636            }
5637        }
5638        // Roster/banlist moves are invisible in the returned community (they live in
5639        // their own columns), so callers that key a refresh off `updated` would never
5640        // repaint a promote/demote/ban. Announce from the single fold point — it covers
5641        // realtime, boot catch-up and manual sync alike.
5642        if authority_changed {
5643            crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
5644        }
5645        // Owner-side silent Admin widening (PIN_MESSAGES): after the roster has
5646        // folded, so the check reads the settled roles. Once per community per
5647        // process — the fold runs constantly and the upgrade is a one-shot.
5648        {
5649            static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
5650            let first = UPGRADED
5651                .lock()
5652                .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
5653                .unwrap_or(false);
5654            if first {
5655                let _ = upgrade_admin_role_pin_bit(transport, community).await;
5656            }
5657        }
5658        // The pins/upgrade tail awaited network I/O — re-validate before the vault save.
5659        // An adopted staff key rides whatever gets saved (the fold's update, or the
5660        // otherwise-unchanged community) — the vault write that makes it durable.
5661        let mut updated = fold.updated;
5662        if let Some(cr) = adopted_control_root {
5663            let mut u = updated.take().unwrap_or_else(|| community.clone());
5664            u.control_root = Some(cr);
5665            updated = Some(u);
5666        }
5667        match updated {
5668            Some(u) => {
5669                crate::db::community::save_community_v2(&u)?;
5670                Ok(Some(u))
5671            }
5672            None => Ok(None),
5673        }
5674    })
5675    .await
5676}
5677
5678/// Adopt the `control_root` a staff-making Grant delivered to ME (CORD-04 §3).
5679///
5680/// Nothing to adopt on a legacy epoch (no write key exists) or when the secret
5681/// is already held. Candidate editions are MY grant coordinate's, newest
5682/// version first; authority-gating them is unnecessary for adoption — the
5683/// derive check IS the gate (only the true secret derives to the held
5684/// `control_pk`, so the worst a forged edition can do is fail it). The epoch
5685/// rides inside the ciphertext because staleness is structural: compaction
5686/// re-wraps a Grant head verbatim across Refoundings, and staff crossing a
5687/// rotation get the new secret in their 136-byte base blob instead.
5688async fn adopt_my_control_wrap(community: &CommunityV2, editions: &[ParsedEdition]) -> Option<[u8; 32]> {
5689    use nostr_sdk::prelude::AsyncNip44;
5690    let held_pk = community.control_pk?;
5691    if community.control_root.is_some() {
5692        return None;
5693    }
5694    let my_pk = me_pk().ok()?;
5695    let signer = crate::signer::active_signer().ok()?;
5696    let my_eid = super::derive::grant_locator(community.id(), &my_pk.to_bytes());
5697    let mut candidates: Vec<&ParsedEdition> = editions
5698        .iter()
5699        .filter(|e| e.vsk == vsk::GRANT && e.entity_id == my_eid)
5700        .collect();
5701    candidates.sort_by(|a, b| b.version.cmp(&a.version));
5702    for ed in candidates {
5703        let Some(wrap_b64) = super::roles::parse_grant_control_wrap(&ed.content) else { continue };
5704        // The granter is the edition's sealed author — the pairwise key's other
5705        // half (one ECDH either side can compute; bunker accounts included).
5706        let Ok(plain_b64) = signer.nip44_decrypt_async(&ed.author, &wrap_b64).await else { continue };
5707        let Ok(plain) = base64_simd::STANDARD.decode_to_vec(plain_b64.as_bytes()) else { continue };
5708        let Ok((epoch, root)) = super::rekey::parse_control_wrap(&plain) else { continue };
5709        if epoch != community.root_epoch {
5710            continue;
5711        }
5712        if super::derive::control_signer_group_key(&root, community.id(), epoch).pk() != held_pk {
5713            continue;
5714        }
5715        crate::log_info!("[v2] adopted the staff write key from my Grant (epoch {})", epoch.0);
5716        return Some(root);
5717    }
5718    None
5719}
5720
5721/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
5722/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
5723///
5724/// Nearly free to raise: both follow loops exit the moment the fold stops being
5725/// gapped, so the cap only binds when something is genuinely missing — exactly
5726/// when paging further is what's wanted. The old ceiling of 4 (~2k editions) sat
5727/// under a plane that 100 roles + 400 grants already outgrows before counting
5728/// superseded versions, which accumulate until a compaction retires them.
5729const FOLLOW_MAX_PAGES: usize = 32;
5730const FOLLOW_PAGE: usize = 500;
5731/// Page ceiling for a COMPACTION read (CORD-06 §3: a Refounder that cannot fold
5732/// every Control Event must abort). Far above any real plane, but plane depth is
5733/// attacker-controlled — any member holds the key that mints wraps — so the read
5734/// is bounded and reports coming up short rather than compacting a partial view.
5735const COMPACT_MAX_PAGES: usize = 512;
5736
5737/// A folded control head to persist as the per-entity refuse-downgrade floor.
5738#[derive(Clone)]
5739struct FoldedHead {
5740    entity_hex: String,
5741    version: u64,
5742    self_hash: [u8; 32],
5743    inner_id: [u8; 32],
5744}
5745
5746/// The outcome of a floor-aware control fold: the updated community (if content
5747/// changed), the heads to persist as the new floor (returned even when content is
5748/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
5749/// hit an unresolvable gap — the caller's signal to page older history and re-fold
5750/// (CORD-04 H1/M8's refetch).
5751struct ControlFold {
5752    updated: Option<CommunityV2>,
5753    heads: Vec<FoldedHead>,
5754    gapped: bool,
5755    /// Folded Pin List heads to persist:
5756    /// `(channel_hex, raw content, version, author_npub, created_at)`.
5757    /// Raw carried bytes on purpose — republishing must not re-serialize.
5758    pins_persist: Vec<(String, String, u64, String, u64)>,
5759}
5760
5761/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
5762type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
5763
5764/// Fold owner-authored control editions into an updated community using the
5765/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
5766/// [`version::fold`]`(floor, floor_hash)`:
5767///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
5768///     doesn't block the verified prefix — refuse-downgrade holds for everything
5769///     applied — but flags `gapped` so the caller pages for the rest.
5770///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
5771///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
5772///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
5773///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
5774///     client lands on the same head where a hash-strict floor would wedge forever.
5775///     Anything else is withholding → fail closed + `gapped`.
5776///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
5777///     Refounding, since the caller epoch-filters the floor) takes the highest
5778///     signed head (author already owner-filtered).
5779/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
5780/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
5781/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
5782/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
5783/// convergence preserved).
5784fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
5785    use crate::community::roles::Permissions;
5786    use std::collections::BTreeMap;
5787
5788    let owner_hex = community.owner().ok().map(|o| o.to_hex());
5789
5790    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
5791    for e in editions {
5792        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
5793    }
5794
5795    let mut out = community.clone();
5796    let mut changed = false;
5797    let mut heads = Vec::new();
5798    let mut gapped = false;
5799    let mut pins_persist = Vec::new();
5800    // Pin List eids are one-way HKDF locators, so attribution runs the other
5801    // direction: precompute every known channel's locator. An eid matching no
5802    // channel folds nothing this round — once the channel's metadata lands, the
5803    // next fold attributes it (editions re-fold from the window each sync).
5804    let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
5805        .channels
5806        .iter()
5807        .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
5808        .collect();
5809    for ((vsk_code, eid), group) in &groups {
5810        // This fold applies three entities: community metadata (eid ==
5811        // community_id), channel metadata, and per-channel Pin Lists. A vsk-2
5812        // whose eid equals the community id is excluded — the floor row keys on
5813        // the entity alone, so it would share (and corrupt) the metadata
5814        // chain's floor.
5815        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
5816        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
5817        let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
5818        if !is_meta && !is_channel && pins_channel.is_none() {
5819            continue;
5820        }
5821        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
5822        // entity's management bit are eligible. Pre-filtering before the fold means a
5823        // demoted admin's (possibly higher-version) edition can't be the head; the
5824        // highest AUTHORIZED head wins. The owner is supreme.
5825        let required = if is_meta {
5826            Permissions::MANAGE_METADATA
5827        } else if is_channel {
5828            Permissions::MANAGE_CHANNELS
5829        } else {
5830            Permissions::PIN_MESSAGES
5831        };
5832        let authed: Vec<&ParsedEdition> = group
5833            .iter()
5834            .copied()
5835            .filter(|e| {
5836                let author = e.author.to_hex();
5837                // A banned npub's edits are dropped (CORD-04 §4), even if they still
5838                // held a bit via a not-yet-stripped grant.
5839                !authority.banned.contains(&author)
5840                    && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
5841                    // …and the CORD-04 §5 sync floor. Resolved against the Grant heads
5842                    // this same fold settled, so it works on a bootstrap where no
5843                    // persisted head exists yet.
5844                    && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
5845            })
5846            .collect();
5847        if authed.is_empty() {
5848            continue;
5849        }
5850        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
5851        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
5852        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
5853        gapped |= entity_gapped;
5854        let Some(hi) = hi else { continue };
5855
5856        let head = authed[hi];
5857        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
5858        if is_meta {
5859            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
5860                changed |= apply_community_metadata(&mut out, meta);
5861            }
5862        } else if is_channel {
5863            if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
5864                // vsk-2 carries no community binding (shared v1 grammar); a same-owner
5865                // cross-community replay can inject a phantom PUBLIC channel (bounded:
5866                // root-scoped key, eids don't collide). Binding is a deferred wire change.
5867                changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
5868            }
5869        } else if let Some(channel_hex) = pins_channel {
5870            // The RAW carried content, cap-violations included: readers judge
5871            // those (read as empty), and a re-serialization here would break the
5872            // byte cap's meaning and every republish's fidelity.
5873            use nostr_sdk::prelude::ToBech32;
5874            let author_npub = head
5875                .author
5876                .to_bech32()
5877                .unwrap_or_else(|_| head.author.to_hex());
5878            pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
5879        }
5880    }
5881    ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
5882}
5883
5884/// Fold one entity's editions against its persisted floor into a head index (into the
5885/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
5886/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
5887/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
5888/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
5889/// the lower-inner-id winner; and fail closed otherwise.
5890fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
5891    let floor_v = floor.map(|f| f.0).unwrap_or(0);
5892    if floor_v == 0 {
5893        return (version::bootstrap_head(fold_eds, 0), false);
5894    }
5895    let floor_hash = floor.map(|f| &f.1);
5896    let held_inner = floor.and_then(|f| f.2);
5897    let result = version::fold(fold_eds, floor_v, floor_hash);
5898    if result.anchored {
5899        return (result.head, result.gap); // verified prefix; page any upper gap.
5900    }
5901    if result.head.is_none() && !result.gap {
5902        return (None, false); // everything below floor — a stale relay, no paging.
5903    }
5904    // Unanchored under a held floor: converge a same-version fork at the floor to its
5905    // deterministic winner (lower inner id; a NULL held id is always replaceable),
5906    // else fail closed.
5907    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
5908    let win_hash = match fork {
5909        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
5910        _ => return (None, true), // detached from our committed head → withholding.
5911    };
5912    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
5913    if !re.anchored {
5914        return (None, true);
5915    }
5916    (re.head, re.gap)
5917}
5918
5919/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
5920/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
5921/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
5922/// recomputed from the self-certifying community_id at each use.
5923struct AuthoritySet {
5924    roles: crate::community::roles::CommunityRoles,
5925    banned: std::collections::BTreeSet<String>,
5926    heads: Vec<FoldedHead>,
5927    gapped: bool,
5928    /// The authorized banlist `(content, version)` to persist when an authorized head
5929    /// advanced the floor. `None` when the banlist was retained (no new authorized
5930    /// head) or is empty — the caller then leaves the stored banlist untouched.
5931    banlist_persist: Option<(Vec<String>, u64)>,
5932    /// Ban HISTORY: npub hex → `created_at` (secs) of the newest authorized edition that
5933    /// named them, across every edition in the window rather than just the head. Outlives
5934    /// the ban itself so an un-ban can't resurrect a phantom (see [`fold_members`]).
5935    banned_at: std::collections::BTreeMap<String, u64>,
5936}
5937
5938impl AuthoritySet {
5939    /// Bootstrap authority for a community with no roster editions folded yet: only
5940    /// the owner is authorized (supreme), nobody banned.
5941    fn owner_only() -> Self {
5942        AuthoritySet {
5943            roles: Default::default(),
5944            banned: Default::default(),
5945            heads: vec![],
5946            gapped: false,
5947            banlist_persist: None,
5948            banned_at: Default::default(),
5949        }
5950    }
5951}
5952
5953/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
5954/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
5955/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
5956/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
5957/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
5958/// banlist is enforced only if its head's signer held BAN in the authorized roster.
5959fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
5960    use crate::community::roles::Permissions;
5961    use std::collections::BTreeMap;
5962
5963    let cid = community.id();
5964    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5965    let owner = community.owner().ok();
5966    let owner_hex = owner.map(|o| o.to_hex());
5967    let banlist_eid = super::derive::banlist_locator(cid);
5968    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
5969
5970    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
5971    for e in editions {
5972        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
5973            groups.entry(e.entity_id).or_default().push(e);
5974        }
5975    }
5976
5977    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
5978    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
5979    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
5980    // to the next candidate, so a forged higher-version edition can't suppress the
5981    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
5982    // let any member vanish a role or a member's grant). `gapped` (drives older-
5983    // paging) stays fold_head's per-entity flag.
5984    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5985    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5986    let mut gapped = false;
5987
5988    for (eid, group) in &groups {
5989        // The banlist is folded author-aware AFTER the roster is known (below).
5990        if *eid == banlist_eid {
5991            continue;
5992        }
5993        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
5994        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
5995        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
5996        gapped |= entity_gapped;
5997        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
5998
5999        for p in group {
6000            // Refuse-downgrade: never consider an edition below the persisted floor.
6001            if p.version < floor_v {
6002                continue;
6003            }
6004            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
6005            match p.vsk.as_str() {
6006                vsk::ROLE => {
6007                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
6008                    if let Some(role) = super::roles::parse_role_content(&p.content) {
6009                        if role.role_id == entity_hex && role.position != 0 {
6010                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
6011                        }
6012                    }
6013                }
6014                vsk::GRANT => {
6015                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
6016                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
6017                            if super::derive::grant_locator(cid, &member) == *eid {
6018                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
6019                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
6020                            }
6021                        }
6022                    }
6023                }
6024                _ => {}
6025            }
6026        }
6027    }
6028    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
6029        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
6030    }
6031
6032    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
6033    // Preliminary roster (bans not yet applied) — the authority view the banlist head
6034    // is judged against.
6035    let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
6036
6037    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
6038    // both closed:
6039    //   - head selection: the head is the highest version whose author CURRENTLY holds
6040    //     BAN — an unauthorized higher-version edition can't erase existing bans
6041    //     (fail-open), and the floor never advances to one;
6042    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
6043    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
6044    //     owner is unbannable);
6045    //   - withholding: when no authorized head is served, the persisted banlist is
6046    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
6047    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6048    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
6049    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
6050    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
6051    // head eligibility, not just from the roster.
6052    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
6053    let banlist_authored: Vec<&ParsedEdition> = groups
6054        .get(&banlist_eid)
6055        .map(|g| {
6056            g.iter()
6057                .copied()
6058                .filter(|e| {
6059                    let ah = e.author.to_hex();
6060                    !banned_authors.contains(ah.as_str())
6061                        && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
6062                        && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
6063                })
6064                .collect()
6065        })
6066        .unwrap_or_default();
6067    // Ban history for phantom suppression: the newest AUTHORIZED edition naming each npub,
6068    // over EVERY candidate rather than only the head — an un-ban replaces the head, so the
6069    // head alone forgets the ban that the suppression exists to remember. The owner is
6070    // skipped: they are never bannable, and a moderator listing them must not durably
6071    // suppress them past the un-ban.
6072    let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
6073    for p in &banlist_authored {
6074        for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
6075            if owner_hex.as_deref() == Some(t.as_str()) {
6076                continue;
6077            }
6078            let slot = banned_at.entry(t).or_insert(0);
6079            *slot = (*slot).max(p.created_at);
6080        }
6081    }
6082    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
6083    let mut banlist_head: Option<FoldedHead> = None;
6084    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
6085        persisted_banned.into_iter().collect()
6086    } else {
6087        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
6088        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
6089        gapped |= g;
6090        match hi {
6091            Some(hi) => {
6092                let head = banlist_authored[hi];
6093                let ah = head.author.to_hex();
6094                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
6095                    .unwrap_or_default()
6096                    .into_iter()
6097                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
6098                    .take(super::roles::MAX_BANLIST)
6099                    .collect();
6100                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
6101                banlist_persist = Some((list.clone(), head.version));
6102                list.into_iter().collect()
6103            }
6104            None => persisted_banned.into_iter().collect(),
6105        }
6106    };
6107
6108    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
6109    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
6110    // the banned set excluded so a banned admin loses authority.
6111    let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
6112    if let Some(bh) = banlist_head {
6113        heads.push(bh);
6114    }
6115
6116    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
6117    // authorization, so an attacker's unauthorized roles can't consume cap slots and
6118    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
6119    // roles empty the roster).
6120    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
6121        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
6122        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
6123        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
6124        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
6125        authorized.grants.retain(|g| !g.role_ids.is_empty());
6126    }
6127
6128    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
6129}
6130
6131/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
6132/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
6133struct AuthorityCand {
6134    role: Option<crate::community::roles::Role>,
6135    grant: Option<crate::community::roles::MemberGrant>,
6136    author: PublicKey,
6137    head: FoldedHead,
6138    /// The `vac` this edition carried (CORD-04 §5). `None` for an owner edition
6139    /// (supreme, cites nothing) or an uncited one — the latter is refused.
6140    citation: Option<crate::community::edition::AuthorityCitation>,
6141}
6142
6143/// CORD-04 §5 sync floor, resolved against the heads THIS fold pass has accepted.
6144///
6145/// Deliberately not the persisted-head helper the kick/hide paths use: this IS the
6146/// pass that establishes those heads, so an external floor would refuse every
6147/// non-owner edition on a bootstrap and the roster could never fold. Same rule the
6148/// spec gives for a dangling `prev` across a Refounding — a fresh joiner takes the
6149/// authority-verified head as its baseline, a tracking client fails closed per
6150/// entity — applied to the citation instead of the chain link.
6151fn citation_ok_in_fold(
6152    cid: &crate::community::CommunityId,
6153    heads: &[FoldedHead],
6154    owner_hex: Option<&str>,
6155    author: &PublicKey,
6156    citation: Option<&crate::community::edition::AuthorityCitation>,
6157) -> bool {
6158    let actor_hex = author.to_hex();
6159    if owner_hex == Some(actor_hex.as_str()) {
6160        return true;
6161    }
6162    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
6163    let as_entity: Vec<crate::community::roster::EntityHead> = heads
6164        .iter()
6165        .map(|h| crate::community::roster::EntityHead {
6166            entity_hex: h.entity_hex.clone(),
6167            version: h.version,
6168            self_hash: h.self_hash,
6169            inner_id: h.inner_id,
6170            citation: None,
6171        })
6172        .collect();
6173    crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
6174}
6175
6176/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
6177/// takes the highest-version candidate whose author is authorized to author it under
6178/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
6179/// vanishing the entity. Authority resolves outward from the owner (proven by
6180/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
6181/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
6182/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
6183/// advances only to authorized heads — an unauthorized forgery never poisons it).
6184fn select_authorized(
6185    cid: &crate::community::CommunityId,
6186    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
6187    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
6188    owner_hex: Option<&str>,
6189    excluded: &std::collections::BTreeSet<String>,
6190) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
6191    use crate::community::roles::{CommunityRoles, Permissions};
6192    let mut accepted = CommunityRoles::default();
6193    let mut heads: Vec<FoldedHead> = Vec::new();
6194    // Jacobi iteration: authority propagates one delegation level per round, so a
6195    // generous multiple of the entity count is an ample bound. Non-convergence (never
6196    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
6197    // are ever selected.
6198    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
6199    for _ in 0..bound {
6200        let mut next = CommunityRoles::default();
6201        let mut next_heads: Vec<FoldedHead> = Vec::new();
6202
6203        for cands in role_cands.values() {
6204            // Two gates, not one (CORD-04 §2). Minting at a position you outrank
6205            // is necessary but not sufficient: an edition REPLACES the entity, so
6206            // the author must also outrank the position standing before it.
6207            // Without that, an admin at position 5 rewrites the position-1 role
6208            // to position 9 — every check passes, since 9 is beneath them — and
6209            // a role that outranked them is now beneath them, along with everyone
6210            // holding it. Rank inversion by republish.
6211            //
6212            // The chain is replayed ASCENDING so each version is judged against
6213            // the position its own predecessor established, then the highest
6214            // admissible version wins (candidates arrive version-DESC, forks
6215            // broken by lowest inner_id — preserved by walking version groups).
6216            let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
6217            let mut standing: Option<u32> = None;
6218            let mut i = cands.len();
6219            while i > 0 {
6220                let hi = i;
6221                let ver = cands[i - 1].head.version;
6222                while i > 0 && cands[i - 1].head.version == ver {
6223                    i -= 1;
6224                }
6225                // One winner per version: fork siblings can't sidestep the gate.
6226                for c in cands[i..hi].iter().rev() {
6227                    let Some(role) = &c.role else { continue };
6228                    let ah = c.author.to_hex();
6229                    if excluded.contains(&ah) || role.position == 0 {
6230                        continue;
6231                    }
6232                    if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
6233                        continue;
6234                    }
6235                    if let Some(prev) = standing {
6236                        if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
6237                            continue;
6238                        }
6239                    }
6240                    if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
6241                        continue;
6242                    }
6243                    admissible.insert(c.head.self_hash);
6244                    standing = Some(role.position);
6245                    break;
6246                }
6247            }
6248            for c in cands {
6249                let Some(role) = &c.role else { continue };
6250                if !admissible.contains(&c.head.self_hash) {
6251                    continue;
6252                }
6253                next.roles.push(role.clone());
6254                next_heads.push(c.head.clone());
6255                break; // highest admissible candidate for this entity
6256            }
6257        }
6258        for cands in grant_cands.values() {
6259            for c in cands {
6260                let Some(grant) = &c.grant else { continue };
6261                let ah = c.author.to_hex();
6262                if excluded.contains(&ah) || excluded.contains(&grant.member) {
6263                    continue;
6264                }
6265                // The granter must outrank every granted role (resolved against the
6266                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
6267                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
6268                let Some(positions) = positions else { continue };
6269                if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
6270                    continue;
6271                }
6272                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
6273                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
6274                {
6275                    // Record the head even for an EMPTY grant (a revoke is a real chain
6276                    // advance a completeness check must see), but don't carry the husk
6277                    // into the roster.
6278                    next_heads.push(c.head.clone());
6279                    if !grant.role_ids.is_empty() {
6280                        next.grants.push(grant.clone());
6281                    }
6282                    break;
6283                }
6284            }
6285        }
6286
6287        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
6288        accepted = next;
6289        heads = next_heads;
6290        if converged {
6291            break;
6292        }
6293    }
6294    (accepted, heads)
6295}
6296
6297/// Apply a folded community-metadata head. Relays only overwrite when the edition
6298/// carries a non-empty list (a metadata edition that omits relays must not blank
6299/// the working set). Returns whether anything changed.
6300fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
6301    let mut changed = false;
6302    if out.name != meta.name {
6303        out.name = meta.name;
6304        changed = true;
6305    }
6306    if out.description != meta.description {
6307        out.description = meta.description;
6308        changed = true;
6309    }
6310    // Icon/banner apply verbatim, None included — an edition is the full
6311    // document, so an absent image IS a removal (editors preserve via
6312    // `CommunityV2::metadata()`).
6313    if out.icon != meta.icon {
6314        out.icon = meta.icon;
6315        changed = true;
6316    }
6317    if out.banner != meta.banner {
6318        out.banner = meta.banner;
6319        changed = true;
6320    }
6321    // Client-extensible + unknown fields ride the fold verbatim so our own
6322    // editions can carry them forward (CORD-02 §6).
6323    if out.meta_custom != meta.custom {
6324        out.meta_custom = meta.custom;
6325        changed = true;
6326    }
6327    if out.meta_extra != meta.extra {
6328        out.meta_extra = meta.extra;
6329        changed = true;
6330    }
6331    // CAP on the way in. `cap_relays` is the truncate-on-read invariant for every
6332    // other construction boundary, and the fold is a boundary like any other: an
6333    // authorized editor is not a trusted one, and an oversize list costs every
6334    // member a fan-out on each publish and the slowest of N on each fetch
6335    // (CORD-02 §6 makes trimming explicitly a client's call). Compare against the
6336    // CAPPED list too — against the raw one, an oversize edition never compares
6337    // equal, so every fold would report a change and re-save forever.
6338    let relays = crate::community::cap_relays(meta.relays);
6339    if !relays.is_empty() && out.relays != relays {
6340        out.relays = relays;
6341        changed = true;
6342    }
6343    changed
6344}
6345
6346/// Apply a folded channel-metadata head: delete removes the channel, a rename
6347/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
6348/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
6349/// rekey plane or a fresh bundle). Returns whether anything changed.
6350fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
6351    let deleted = meta.deleted.unwrap_or(false);
6352    if deleted {
6353        let before = out.channels.len();
6354        out.channels.retain(|c| c.id.0 != id.0);
6355        return out.channels.len() != before;
6356    }
6357    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
6358        Some(existing) => {
6359            let mut changed = false;
6360            if existing.name != meta.name {
6361                existing.name = meta.name;
6362                changed = true;
6363            }
6364            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
6365            // local edit republishes them instead of wiping (CORD-02 §6).
6366            if existing.voice != meta.voice {
6367                existing.voice = meta.voice;
6368                changed = true;
6369            }
6370            if existing.meta_custom != meta.custom {
6371                existing.meta_custom = meta.custom;
6372                changed = true;
6373            }
6374            if existing.meta_extra != meta.extra {
6375                existing.meta_extra = meta.extra;
6376                changed = true;
6377            }
6378            // The owner's edition authoritatively declares visibility. A channel the
6379            // owner marks PUBLIC must derive from the root (key = None) — this heals a
6380            // bundle-time misclassification where an attacker set a public channel's
6381            // grant key to their own, silently addressing it at a plane only they read.
6382            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
6383            // record stays public) until the convert flow (key mint + cursor rebase
6384            // to the conversion's channel epoch) lands — the send side refuses to
6385            // publish one, and a foreign client's conversion won't move us.
6386            if !meta.private && (existing.private || existing.key.is_some()) {
6387                existing.private = false;
6388                existing.key = None;
6389                changed = true;
6390            }
6391            changed
6392        }
6393        None if !meta.private => {
6394            // A public channel derives its Chat Plane from the community_root at the
6395            // current root epoch (key = None); its stored epoch mirrors the root.
6396            out.channels.push(ChannelV2 {
6397                id,
6398                name: meta.name,
6399                private: false,
6400                key: None,
6401                epoch: out.root_epoch,
6402                voice: meta.voice,
6403                meta_custom: meta.custom,
6404                meta_extra: meta.extra,
6405            });
6406            true
6407        }
6408        None => {
6409            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
6410            // generation — CORD-03 §2 numbers the first private key epoch 1). The
6411            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
6412            // delivers a key, every read/send/subscribe path skips the channel; the
6413            // root-fallback in `channel_secret` is never taken for it.
6414            out.channels.push(ChannelV2 {
6415                id,
6416                name: meta.name,
6417                private: true,
6418                key: None,
6419                epoch: Epoch(0),
6420                voice: meta.voice,
6421                meta_custom: meta.custom,
6422                meta_extra: meta.extra,
6423            });
6424            true
6425        }
6426    }
6427}
6428
6429// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
6430
6431/// The outcome of a rekey-follow pass.
6432pub struct RekeyFollow {
6433    /// The community after adopting every rotation it could catch up on, or `None`
6434    /// if nothing advanced.
6435    pub updated: Option<CommunityV2>,
6436    /// A base rotation removed us — the caller tears the local hold down (the
6437    /// updated community is not persisted in that case).
6438    pub self_removed: bool,
6439    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
6440    /// flag is already set; the caller surfaces the death and stops following.
6441    pub dissolved: bool,
6442    /// A complete authorized rotation carried a blob AT OUR LOCATOR that would
6443    /// not open (a width this build predates, a rotator bug, or a deliberately
6444    /// undecryptable blob planted to pass an admissibility check while severing
6445    /// us). Never a removal (CORD-06 §2: removal is the ABSENCE of a blob), so
6446    /// the walk parks — but the park is otherwise indistinguishable from "no
6447    /// rotation happened", which is exactly what makes it abusable. Surfaced so
6448    /// the user learns their key delivery is broken while the prior root still
6449    /// reaches everyone.
6450    pub wedged: bool,
6451}
6452
6453/// The most archived base roots a channel-rekey lookup fans across per step. A
6454/// standalone rekey rides the minter's then-current root and a removal's rides the
6455/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
6456/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
6457/// an older root than the fan reaches) only heals via a fresh invite bundle — the
6458/// walk is strictly sequential, so a later rotation can't be reached either.
6459const MAX_ADDRESSING_ROOTS: usize = 8;
6460
6461/// The base roots a channel rekey may be addressed under, freshest first: the
6462/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
6463/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
6464/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
6465/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
6466/// fetch addresses but auth never registered is invisible on an AUTH-gating
6467/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
6468/// wedges at its old epoch while the base advances.
6469pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
6470    let mut roots: Vec<[u8; 32]> = vec![cur_root];
6471    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
6472        .unwrap_or_default();
6473    archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
6474    for (_, r) in archived {
6475        if !roots.contains(&r) {
6476            roots.push(r);
6477        }
6478    }
6479    roots.truncate(MAX_ADDRESSING_ROOTS);
6480    roots
6481}
6482
6483/// Follow rekeys for a held community: advance the base (root) epoch and each
6484/// Private channel's epoch as far as authorized rotations allow, adopting the
6485/// fresh key we're still a recipient of at each step and dropping a scope we've
6486/// been removed from. Persists the result. Called when a rekey wrap arrives in
6487/// realtime so a long-running bot keeps decrypting after a rotation instead of
6488/// going silent.
6489///
6490/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
6491/// or any `BAN` holder, and a CHANNEL rotation from the owner or a
6492/// `MANAGE_CHANNELS` holder — both under the PERSISTED roster (folded by
6493/// `follow_control`), minus the banlist, so an admin-created private channel
6494/// keys up on every member. Concluding OUR OWN removal takes more than the bit:
6495/// the rotator must strictly outrank us (`*_outranks_me`), so an equal-rank
6496/// admin can never evict a peer by minting a rotation that skips their blob.
6497///
6498/// **Addressing fans across held base roots:** each channel step queries its
6499/// next-epoch rekey address under the current root AND the archived prior roots,
6500/// so a base adopt landing before a Refounding's prior-root-addressed channel
6501/// rekeys (or before a creation delivery minted under an older root) can't
6502/// strand the channel.
6503///
6504/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
6505/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
6506/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
6507/// every follower converges. An incomplete rotation (a missing chunk) never
6508/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
6509/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
6510/// §2: "a convergence check, not a secrecy mechanism") — authority is its
6511/// boundary; its epoch is the scan cursor, advancing past complete rotations
6512/// that exclude us so the walk converges on the channel's current epoch.
6513/// Diagnostic: run the base-rotation fetch+parse pipeline for a wedged community
6514/// and report, per rotation found at the next-epoch base plane, WHY
6515/// `follow_rekeys` did or didn't adopt it — the exact `advance_scope` gate that
6516/// tripped. Read-only. Every rotator/owner is a PUBLIC key; no secret material
6517/// is returned.
6518#[cfg(debug_assertions)]
6519pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
6520    transport: &T,
6521    community: &CommunityV2,
6522) -> Result<serde_json::Value, String> {
6523    let my_xonly = me_pk()?.to_bytes();
6524    let owner = community.owner()?;
6525    let owner_hex = owner.to_hex();
6526    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6527    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6528    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6529    let held_epoch = community.root_epoch;
6530    let held_key = community.community_root;
6531    let next = Epoch(held_epoch.0.saturating_add(1));
6532    let group = base_rekey_group_key(&held_key, community.id(), next);
6533    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
6534    let rotations = rekey::collect_rotations(&chunks);
6535
6536    let reports: Vec<serde_json::Value> = rotations
6537        .iter()
6538        .map(|r| {
6539            let rotator_is_owner = r.rotator == owner;
6540            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
6541            // Roster, not owner-identity — report that gate, not just owner-equality.
6542            let rotator_authorized = rotator_is_owner
6543                || (!banned.contains(&r.rotator.to_hex())
6544                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
6545            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
6546            let epoch_ok = r.new_epoch.0 == next.0;
6547            let complete = r.is_complete();
6548            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
6549            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
6550            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
6551            // is a takeover attempt — this tells whether an "owner must be kept"
6552            // adopt-block would be safe here (it would falsely reject a legitimate
6553            // rotation that happened to exclude the owner).
6554            let owner_kept = r.rotator == owner
6555                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
6556            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
6557            let verdict = if !rotator_authorized {
6558                "REJECTED: rotator holds no BAN authority in the folded roster"
6559            } else if !scope_ok {
6560                "REJECTED: scope is not Root"
6561            } else if !epoch_ok {
6562                "REJECTED: new_epoch != held+1"
6563            } else if !complete {
6564                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
6565            } else if continuity != "Extends" {
6566                "REJECTED: continuity does not extend my held root (FORK/GAP)"
6567            } else if has_my_blob {
6568                "ADOPT: authorized + complete + continuous + my blob present"
6569            } else {
6570                "REMOVED: complete authorized rotation with no blob for me"
6571            };
6572            serde_json::json!({
6573                "rotator": r.rotator.to_hex(),
6574                "rotator_is_recorded_owner": rotator_is_owner,
6575                "rotator_authorized_ban": rotator_authorized,
6576                "scope_is_root": scope_ok,
6577                "new_epoch": r.new_epoch.0,
6578                "prev_epoch": r.prev_epoch.0,
6579                "declared_chunks": r.declared_chunks,
6580                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
6581                "is_complete": complete,
6582                "continuity_vs_held_root": continuity,
6583                "my_blob_present": has_my_blob,
6584                "owner_kept": owner_kept,
6585                "blob_count": r.blobs.len(),
6586                "verdict": verdict,
6587            })
6588        })
6589        .collect();
6590
6591    Ok(serde_json::json!({
6592        "recorded_owner": owner.to_hex(),
6593        "held_root_epoch": held_epoch.0,
6594        "probing_next_epoch": next.0,
6595        "base_plane_pk": group.pk_hex(),
6596        "raw_chunks_parsed": chunks.len(),
6597        "rotations_found": rotations.len(),
6598        "rotations": reports,
6599    }))
6600}
6601
6602pub async fn follow_rekeys<T: Transport + ?Sized>(
6603    transport: &T,
6604    community: &CommunityV2,
6605    session: &std::sync::Arc<crate::db::Session>,
6606) -> Result<RekeyFollow, String> {
6607    crate::db::scoped(async move {
6608        // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
6609        // past its tombstone — don't adopt a rotation into a grave.
6610        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6611        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
6612            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
6613        }
6614        // An offline member must also LEARN of a death: the tombstone rides its own
6615        // public plane, which the live sub watches but no catch-up fetch touched —
6616        // without this, a member who slept through a dissolution follows (and posts
6617        // into) a grave forever. Fail-open on transport failure: availability is
6618        // never death.
6619        if is_dissolved(transport, community).await {
6620            if session.is_live() {
6621                let _ = crate::db::community::set_community_dissolved(&cid_hex);
6622            }
6623            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
6624        }
6625        let signer = crate::signer::active_signer()?;
6626        let my_pk = me_pk()?;
6627        let my_xonly = my_pk.to_bytes();
6628        let owner = community.owner()?;
6629        let owner_hex = owner.to_hex();
6630        let mut cur = community.clone();
6631        let mut changed = false;
6632        // A rotation delivered us a blob we could not open (see RekeyFollow::wedged).
6633        let mut wedged = false;
6634
6635        // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
6636        // follow_control; the worker folds control right after this rekey pass). This
6637        // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
6638        // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
6639        // the base-admissibility protected-set: a superior whose grant this receiver
6640        // has not yet folded is not in `roster.grants`, so a non-owner Refounding
6641        // excluding them can be adopted within that propagation window. Bounded — the
6642        // owner is ALWAYS hard-protected below (independent of the roster) and can
6643        // counter-refound; and it is inherent to eventual consistency (one cannot gate
6644        // on a grant never seen). Tightening this (fold control before the first rekey,
6645        // or gate non-owner adoption on roster freshness) is a follow-on.
6646        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6647        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6648        let me_hex = my_pk.to_hex();
6649        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
6650        // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
6651        // never honored by a lagging client." Persisted heads ARE the right floor
6652        // here (unlike the roster fold, which must resolve in-pass): a rotation is
6653        // judged against a roster we already folded, and `follow_control` — v2's only
6654        // roster writer — persists the heads in the same pass it writes the roster.
6655        // A joiner who sees a rotation before folding control simply parks it and
6656        // heals on the next follow, which runs control first.
6657        let cited_ok = |rot: &rekey::Rotation| -> bool {
6658            citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
6659        };
6660        let channel_rotator_ok = |rotator: &PublicKey| -> bool {
6661            if *rotator == owner {
6662                return true;
6663            }
6664            let rh = rotator.to_hex();
6665            !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
6666        };
6667        // Concluding MY removal takes more than the bit: the rotator must strictly
6668        // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
6669        // removed target"), so an equal-rank admin can never silently evict a peer
6670        // (or the owner) by minting a complete rotation that skips their blob.
6671        let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
6672            if *rotator == owner {
6673                return true;
6674            }
6675            let rh = rotator.to_hex();
6676            !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
6677        };
6678        // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
6679        // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
6680        // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
6681        // owner is always authorized. (Owner-only here silently wedged every member
6682        // whose community was refounded by a non-owner admin.)
6683        let base_rotator_ok = |rotator: &PublicKey| -> bool {
6684            if *rotator == owner {
6685                return true;
6686            }
6687            let rh = rotator.to_hex();
6688            !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
6689        };
6690        // Concluding MY removal via a base rotation takes more than the bit: the
6691        // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
6692        // Rotator must strictly outrank every removed target"), so an equal-rank
6693        // admin can never evict a peer (or the owner) by minting a rotation that
6694        // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
6695        let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
6696            if *rotator == owner {
6697                return true;
6698            }
6699            let rh = rotator.to_hex();
6700            !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
6701        };
6702
6703        // Bound the catch-up: each real step consumes a valid authorized rotation, so a
6704        // finite chain terminates naturally; the cap defends against a relay feeding a
6705        // pathological set.
6706        const MAX_STEPS: usize = 128;
6707        for _ in 0..MAX_STEPS {
6708            let mut advanced = false;
6709
6710            // The roots a channel rekey may be addressed under (re-read each pass —
6711            // a base adopt below changes the head, and its predecessor is already
6712            // archived). Shared with streamauth so the auth registration covers
6713            // exactly this fan.
6714            let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
6715
6716            // Private channels first: a removal-forced channel rekey rides the PRIOR
6717            // root (CORD-06 D2), so read channels before a base adopt moves it.
6718            let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
6719            for cid in channel_ids {
6720                let (held_key, held_epoch) = match cur.channel(&cid) {
6721                    Some(ch) => (ch.key, ch.epoch),
6722                    None => continue,
6723                };
6724                let next = Epoch(held_epoch.0.saturating_add(1));
6725                let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
6726                let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
6727                // root #0 = current, #1.. = archived priors (indices only — root
6728                // bytes are key material and must never reach a log).
6729                for (ri, root) in addressing_roots.iter().enumerate() {
6730                    let group = channel_rekey_group_key(root, &cid, next);
6731                    let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
6732                        Ok(c) => c,
6733                        Err(e) => {
6734                            crate::log_warn!(
6735                                "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
6736                                &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
6737                            );
6738                            return Err(e);
6739                        }
6740                    };
6741                    if chunks.is_empty() {
6742                        continue;
6743                    }
6744                    crate::log_debug!(
6745                        "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
6746                        &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
6747                    );
6748                    batches.push((chunks, held_key.map(|k| (held_epoch, k))));
6749                }
6750                // Keyless-adopt residual (documented, deferred hardening): a malicious
6751                // AUTHORIZED admin can fork a keyless member onto an orphan low-key
6752                // rotation nothing extends (keyed members' continuity filters it out).
6753                // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
6754                // exclude the member outright anyway, so the marginal harm is the wedge
6755                // outliving their demotion.
6756                match advance_scope(&batches, RekeyScope::Channel(cid), cur.id(), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
6757                    Advance::Adopt { new_key, .. } => {
6758                        if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
6759                            ch.key = Some(new_key);
6760                            ch.epoch = next;
6761                        }
6762                        crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
6763                        // The adopter's own multi-epoch archive (the minter archived at
6764                        // mint) — this channel's history stays readable across rotations.
6765                        // fetch_channel compensates for the CURRENT epoch, so a failed
6766                        // archive only bites after the NEXT rotation — surface it.
6767                        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) {
6768                            crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
6769                        }
6770                        advanced = true;
6771                        changed = true;
6772                    }
6773                    Advance::Removed => {
6774                        match held_key {
6775                            // A complete rotation dropped my blob — cut from the channel.
6776                            Some(_) => {
6777                                cur.channels.retain(|c| c.id.0 != cid.0);
6778                            }
6779                            // Keyless scan: this epoch's rotation completed without me.
6780                            // Advance the cursor so the walk converges on the channel's
6781                            // CURRENT epoch — my entry point is its next rotation (whose
6782                            // recipients are the members at that time) or a fresh bundle.
6783                            None => {
6784                                if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
6785                                    ch.epoch = next;
6786                                }
6787                            }
6788                        }
6789                        advanced = true;
6790                        changed = true;
6791                    }
6792                    Advance::Stay => {}
6793                    Advance::StayWedged => {
6794                        // A blob at our locator that will not open: park, never
6795                        // remove, and make the park legible (a silent park is
6796                        // indistinguishable from "no rotation happened").
6797                        wedged = true;
6798                    }
6799                }
6800            }
6801
6802            // Base rotation (Refounding): advances the root + root_epoch, re-addressing
6803            // every public channel, the guestbook, and the control plane by derivation
6804            // (refresh_subscription recomputes the author-set from the new root).
6805            {
6806                let held_epoch = cur.root_epoch;
6807                let held_key = cur.community_root;
6808                let next = Epoch(held_epoch.0.saturating_add(1));
6809                let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
6810                let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
6811                let batches = vec![(chunks, Some((held_epoch, held_key)))];
6812                // A non-owner Refounding may only remove members the rotator strictly
6813                // OUTRANKS. The protected set is the owner plus every grant-holder the
6814                // rotator can't act on with BAN (a peer or superior) — excluding one is
6815                // an authority-escalation takeover, so its rotation is inadmissible.
6816                // Plain members hold no grant and are always outranked by a BAN-holder,
6817                // so removing them is legitimate and needs no memberlist.
6818                let base_admissible = |r: &rekey::Rotation| -> bool {
6819                    if r.rotator == owner {
6820                        return true; // the owner is supreme.
6821                    }
6822                    // Uncited (or citing a Grant we haven't synced) → skip entirely:
6823                    // neither adopt nor conclude a removal, exactly like an
6824                    // unauthorized rotation. It parks and heals on the next follow.
6825                    if !cited_ok(r) {
6826                        return false;
6827                    }
6828                    let rotator_hex = r.rotator.to_hex();
6829                    let has_blob = |xonly: &[u8; 32]| {
6830                        rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
6831                    };
6832                    // The owner is never a valid removed target.
6833                    if !has_blob(&owner.to_bytes()) {
6834                        return false;
6835                    }
6836                    for g in &roster.grants {
6837                        if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
6838                            continue; // self, owner (checked), or an already-authorized removal.
6839                        }
6840                        // A grant-holder the rotator can't act on is a peer/superior.
6841                        if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
6842                            if let Ok(pk) = PublicKey::from_hex(&g.member) {
6843                                if !has_blob(&pk.to_bytes()) {
6844                                    return false; // a peer/superior was excluded.
6845                                }
6846                            }
6847                        }
6848                    }
6849                    true
6850                };
6851                match advance_scope(&batches, RekeyScope::Root, cur.id(), &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
6852                    Advance::Adopt { new_key, control_pk, control_root } => {
6853                        cur.community_root = new_key;
6854                        cur.root_epoch = next;
6855                        // The control pair is the blob's, never inherited: the
6856                        // secret rolls with the root at every Refounding
6857                        // (CORD-02 §2), so a stale pair carried forward would sign
6858                        // (or subscribe) at a dead address.
6859                        //
6860                        // A legacy 72-byte blob therefore DOWNGRADES a split
6861                        // community back to the member-writable plane — the exact
6862                        // flooding surface the split closes. The width stays
6863                        // accepted (transition compat, CORD-06 §3), but once a
6864                        // community has been split this is either an outdated
6865                        // rotator or an attack, so it is never silent.
6866                        if cur.control_pk.is_some() && control_pk.is_none() {
6867                            crate::log_warn!(
6868                                "[v2:follow {}] rotation to epoch {} carried a LEGACY base blob — this community's control plane reverts to the member-writable address",
6869                                &cid_hex[..8.min(cid_hex.len())], next.0
6870                            );
6871                        }
6872                        cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
6873                        cur.control_root = control_root;
6874                        // Archive on adopt: without this, a member who lived through TWO
6875                        // Refoundings loses the middle epoch's public history (only the
6876                        // minter archived it).
6877                        if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
6878                            crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
6879                        }
6880                        advanced = true;
6881                        changed = true;
6882                    }
6883                    Advance::Removed => {
6884                        return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false, wedged: false });
6885                    }
6886                    Advance::Stay => {}
6887                    Advance::StayWedged => {
6888                        // A blob at our locator that will not open: park, never
6889                        // remove, and make the park legible (a silent park is
6890                        // indistinguishable from "no rotation happened").
6891                        wedged = true;
6892                    }
6893                }
6894            }
6895
6896            if !advanced {
6897                break;
6898            }
6899        }
6900
6901        if wedged {
6902            // Legible, and actionable: the prior root still reaches every member,
6903            // so an owner/admin seeing this can counter-rotate before the honest
6904            // chain moves on.
6905            crate::log_warn!(
6906                "[v2:follow {}] a complete rotation delivered a key blob this client cannot open — parked at epoch {} (never treated as a removal)",
6907                &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
6908            );
6909            crate::emit_event(
6910                "community_rekey_wedged",
6911                &serde_json::json!({ "community_id": cid_hex, "held_epoch": cur.root_epoch.0 }),
6912            );
6913        }
6914        if !changed {
6915            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
6916        }
6917        // A leave/delete raced this follow: saving would resurrect the community row
6918        // (the save is an upsert) with no floor rows behind it.
6919        if crate::db::community::community_protocol(community.id())?.is_none() {
6920            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
6921        }
6922        crate::db::community::save_community_v2(&cur)?;
6923        // Carry my own live links across the rotation someone ELSE performed
6924        // (CORD-05 §2). The refounder refreshes only the bundles they can reach —
6925        // their own — so without this every other creator's links keep vending the
6926        // superseded root and drop new joiners onto a dead epoch, which is exactly
6927        // the stranding the stable-URL refresh exists to prevent. Best-effort and
6928        // idempotent: a creator with no links for this community returns early, and
6929        // a failure only delays the heal until the next adoption or refound.
6930        let _ = refresh_public_links(transport, &cur).await;
6931        Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false, wedged })
6932    })
6933    .await
6934}
6935
6936/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
6937/// address.
6938enum Advance {
6939    /// Adopt this fresh key for `next_epoch`. A BASE adoption also carries the
6940    /// next epoch's Control Plane pair from the blob (CORD-06 §1) — the pk in
6941    /// every 104/136-byte blob, the secret only in a staff recipient's 136. The
6942    /// pair is the BLOB's, never inherited: a legacy 72-byte blob carries
6943    /// neither (that epoch's Control folds at the legacy address), and channel
6944    /// rotations never carry any.
6945    Adopt { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]> },
6946    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
6947    Removed,
6948    /// No owner rotation extends my held epoch (yet) — keep the current key.
6949    Stay,
6950    /// A complete authorized rotation carried a blob at MY locator that would
6951    /// not open. Never a removal (CORD-06 §2), but never silent either — the
6952    /// caller surfaces it (see [`RekeyFollow::wedged`]).
6953    StayWedged,
6954}
6955
6956/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
6957async fn fetch_rekey_chunks<T: Transport + ?Sized>(
6958    transport: &T,
6959    relays: &[String],
6960    group: &GroupKey,
6961) -> Result<Vec<rekey::RekeyChunk>, String> {
6962    // A rekey plane address is community_root-derived, so ANY member can seal junk
6963    // 3303s there — a flood (or, organically, a large community's own multi-chunk
6964    // rotation past the newest window) could bury the genuine owner/admin rotation
6965    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
6966    // control pager's discipline) so a buried authorized chunk is still recovered;
6967    // the seal + authority filter downstream drops the junk. Bounded — a sustained
6968    // flood past this depth degrades to "adopt one pass late", never a false state.
6969    const REKEY_PAGE: usize = 200;
6970    const REKEY_MAX_PAGES: usize = 6;
6971    let mut out = Vec::new();
6972    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
6973    let mut until: Option<u64> = None;
6974    let mut oldest: Option<u64> = None;
6975    for _ in 0..REKEY_MAX_PAGES {
6976        let query = Query {
6977            kinds: vec![stream::KIND_WRAP],
6978            authors: vec![group.pk_hex()],
6979            until,
6980            limit: Some(REKEY_PAGE),
6981            ..Default::default()
6982        };
6983        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
6984        // shared user-authed client's REQ for a plane's events is CLOSED, so an
6985        // offline rotation catch-up would return nothing and wedge at the old
6986        // epoch. `fetch_plane` rides a connection authed as the plane itself.
6987        let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
6988        let mut fresh = 0usize;
6989        for w in &wraps {
6990            if !seen.insert(w.id) {
6991                continue;
6992            }
6993            fresh += 1;
6994            let at = w.created_at.as_secs();
6995            if oldest.is_none_or(|o| at < o) {
6996                oldest = Some(at);
6997            }
6998            if let Ok(opened) = stream::open_wrap(w, group) {
6999                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
7000                    out.push(chunk);
7001                }
7002            }
7003        }
7004        // Drained, or a same-second wall the pager can't step past (second-granular
7005        // until) — either way stop; the accumulated set is what advance_scope folds.
7006        if fresh == 0 || wraps.len() < REKEY_PAGE {
7007            break;
7008        }
7009        match oldest {
7010            Some(o) if o > 0 => until = Some(o),
7011            _ => break,
7012        }
7013    }
7014    Ok(out)
7015}
7016
7017/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
7018/// batch pairs the chunks fetched under one root with the continuity to demand of
7019/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
7020/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
7021/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
7022/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
7023/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
7024/// seal authority is the boundary). Among qualifying rotations carrying my blob
7025/// the lexicographically lowest new key wins (convergent). All complete
7026/// candidates without my blob conclude Removed for a KEYED holder only when one
7027/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
7028/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
7029/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
7030async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
7031    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
7032    scope: RekeyScope,
7033    community_id: &crate::community::CommunityId,
7034    rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
7035    rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
7036    admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
7037    signer: &S,
7038    my_xonly: &[u8; 32],
7039    next_epoch: Epoch,
7040) -> Advance {
7041    let mut winners: Vec<rekey::BaseKeyDelivery> = Vec::new();
7042    let mut saw_complete_candidate = false;
7043    let mut saw_outranking_candidate = false;
7044    let mut my_blob_unopenable = false;
7045    let keyed = batches.iter().any(|(_, held)| held.is_some());
7046    for (chunks, held) in batches {
7047        let rotations = rekey::collect_rotations(chunks);
7048        for r in &rotations {
7049            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
7050                continue;
7051            }
7052            if let Some((held_epoch, held_key)) = held {
7053                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
7054                    continue;
7055                }
7056            }
7057            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
7058            // target. An authorized-but-inadmissible rotation (one that excludes
7059            // the owner or a peer/superior the rotator can't act on) is a takeover
7060            // attempt — skip it entirely, so it neither adopts nor concludes a
7061            // removal (it forks; the honest chain wins).
7062            if !admissible(r) {
7063                continue;
7064            }
7065            saw_complete_candidate = true;
7066            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
7067            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
7068                let opened = match scope {
7069                    // Base blobs are width-declared forms (CORD-06 §1).
7070                    RekeyScope::Root => rekey::open_base_blob(signer, &r.rotator, community_id, r.new_epoch, blob).await,
7071                    RekeyScope::Channel(_) => rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob)
7072                        .await
7073                        .map(|k| rekey::BaseKeyDelivery { new_root: k, control_pk: None, control_root: None }),
7074                };
7075                match opened {
7076                    Ok(d) => winners.push(d),
7077                    // A blob AT my locator that won't open is not an exclusion
7078                    // (CORD-06 §2: removal = NO blob across all chunks). It must
7079                    // never conclude Removed below — that is exactly how a
7080                    // pre-split client turned an unreadable width into a false
7081                    // self-removal. Stay and keep recovering instead.
7082                    Err(_) => my_blob_unopenable = true,
7083                }
7084            }
7085        }
7086    }
7087    if !winners.is_empty() {
7088        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
7089        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
7090        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
7091        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
7092        // the same epoch (separate rotations): every follower converges on the same
7093        // lowest new BASE key — the control pair rides the winner's blobs, never
7094        // compared (CORD-06 §3). A wrap served under two addressing roots can't
7095        // double-count: each rekey wrap opens under exactly one root's group key.
7096        let keys: Vec<[u8; 32]> = winners.iter().map(|d| d.new_root).collect();
7097        let idx = rekey::lowest_key_winner(&keys).expect("winners is non-empty");
7098        let w = &winners[idx];
7099        return Advance::Adopt { new_key: w.new_root, control_pk: w.control_pk, control_root: w.control_root };
7100    }
7101    if saw_complete_candidate && !my_blob_unopenable && (!keyed || saw_outranking_candidate) {
7102        Advance::Removed
7103    } else if my_blob_unopenable {
7104        Advance::StayWedged
7105    } else {
7106        Advance::Stay
7107    }
7108}
7109
7110// ── Pins (CORD-04 §7) ────────────────────────────────────────────────────────
7111
7112/// A channel's pin list, read from the locally folded head.
7113#[derive(Debug, serde::Serialize)]
7114pub struct ChannelPins {
7115    /// Entries that passed the full §7 verification, wire order (curator's).
7116    pub pins: Vec<super::pins::VerifiedPin>,
7117    /// The head is sealed under a key epoch this client does not hold: the
7118    /// pins exist but are unreadable. Render as unavailable, NEVER as empty —
7119    /// and a writer seeing this MUST NOT publish (it would drop every entry).
7120    pub sealed: bool,
7121    /// Folded head version (0 = no edition has ever folded).
7122    pub version: u64,
7123}
7124
7125/// The channel's stream conversation key at `epoch`, if this client holds the
7126/// deriving secret: a private channel's held per-epoch key, a public channel's
7127/// held base root at that epoch.
7128fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
7129    let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
7130    // A private plane is never derived from the root value (that would address
7131    // the public plane) — mirrors fetch_channel_history's invariant.
7132    if ch.private && ikm == community.community_root {
7133        return None;
7134    }
7135    let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
7136    group.conv_key().as_bytes().try_into().ok()
7137}
7138
7139/// Read a channel's pins from the locally folded head: unseal (private form),
7140/// verify every entry, keep wire order. Local-only — the control follow is what
7141/// moves the head.
7142pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
7143    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
7144    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7145    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7146    let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
7147        return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
7148    };
7149    let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
7150    let pins = read
7151        .entries
7152        .iter()
7153        .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
7154        .collect();
7155    Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
7156}
7157
7158/// Publish `entries` as the channel's next Pin List edition, in the form the
7159/// channel's folded type mandates, and echo it locally so a follow-up edit
7160/// builds on this write rather than the pre-write fold.
7161async fn publish_pin_list<T: Transport + ?Sized>(
7162    transport: &T,
7163    community: &CommunityV2,
7164    ch: &ChannelV2,
7165    entries: &[super::pins::PinEntry],
7166) -> Result<(), String> {
7167    crate::db::scoped(async move {
7168        let content = if ch.private {
7169            let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
7170            let group = channel_group_key(&key, &ch.id, ch.epoch);
7171            super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
7172        } else {
7173            super::pins::serialize_public_pin_list(entries)?
7174        };
7175        let eid = super::derive::pins_locator(community.id(), &ch.id);
7176        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7177        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
7178        let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
7179        // The version this publish will chain to — mirrors publish_control_edition.
7180        let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
7181            Some((v, _)) => v + 1,
7182            None => 1,
7183        };
7184        crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
7185        publish_control_edition(transport, community, vsk::PINS, &eid, &content).await?;
7186        let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
7187        crate::emit_event(
7188            "community_pins_updated",
7189            &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
7190        );
7191        if let Ok(me) = me_pk() {
7192            use nostr_sdk::prelude::ToBech32;
7193            let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
7194            note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
7195        }
7196        Ok(())
7197    })
7198    .await
7199}
7200
7201/// One centered system row per adopted Pin List edition — "X modified the
7202/// Pins". The id is deterministic on (channel, version), so the publisher's
7203/// echo and every fold that adopts the same edition collapse into one row,
7204/// and a catch-up fold stamps the edition's own time so history sorts true.
7205async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
7206    let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
7207    let inserted = crate::db::events::save_system_event_at(
7208        &event_id,
7209        channel_hex,
7210        crate::stored_event::SystemEventType::PinsModified,
7211        actor_npub,
7212        None,
7213        at_secs,
7214        None,
7215        None,
7216    )
7217    .await
7218    .unwrap_or(false);
7219    if inserted {
7220        crate::emit_event(
7221            "system_event",
7222            &serde_json::json!({
7223                "conversation_id": channel_hex,
7224                "event_id": event_id,
7225                "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
7226                "member_pubkey": actor_npub,
7227            }),
7228        );
7229    }
7230}
7231
7232/// The current entries this writer may build on. Replace-entire cuts sharply
7233/// (§7): an empty view has two innocent causes indistinguishable from an empty
7234/// list, so a writer MUST refuse to build from a list it could not read.
7235fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
7236    let current = read_channel_pins(community, channel_id)?;
7237    if current.sealed {
7238        return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
7239    }
7240    Ok(current.pins.into_iter().map(|p| p.entry).collect())
7241}
7242
7243/// Pin a message: recover its wrap, rebuild its proof, append, republish.
7244///
7245/// The seal is re-fetched from the community relays by the stored wrapper id —
7246/// the DB retains rumors, not seals, and a proof needs the seal verbatim.
7247pub async fn pin_message<T: Transport + ?Sized>(
7248    transport: &T,
7249    community: &CommunityV2,
7250    channel_id: &ChannelId,
7251    rumor_id_hex: &str,
7252) -> Result<(), String> {
7253    crate::db::scoped(async move {
7254        let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
7255        let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7256
7257        let mut entries = writable_pin_entries(community, channel_id)?;
7258        if entries.len() >= super::pins::PIN_MAX_ENTRIES {
7259            return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
7260        }
7261        // Idempotent: re-pinning an already-pinned message is a no-op, not an error.
7262        if entries
7263            .iter()
7264            .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
7265            .any(|v| v.rumor_id == rumor_id_hex)
7266        {
7267            return Ok(());
7268        }
7269
7270        let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
7271            .ok_or("message not found in this device's history")?;
7272        let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
7273
7274        // Recover the wrap verbatim — Full evidence: a pin is a permanent artifact,
7275        // so don't build it from the first relay to answer.
7276        let wraps = transport
7277            .fetch(
7278                &Query {
7279                    ids: vec![wrap_id.clone()],
7280                    kinds: vec![super::stream::KIND_WRAP],
7281                    limit: Some(1),
7282                    evidence: crate::community::transport::Evidence::Full,
7283                    ..Default::default()
7284                },
7285                &community.relays,
7286            )
7287            .await?;
7288        let wrap = wraps
7289            .iter()
7290            .find(|w| w.id.to_hex() == wrap_id)
7291            .ok_or("the message's wrap is no longer served by this community's relays")?;
7292
7293        // The stored row does not retain the rumor's epoch binding, so re-derive it
7294        // the way history reads do: try the channel's every held plane coordinate,
7295        // current epoch first, until the wrap opens AND carries this rumor. The
7296        // open itself verifies the channel + epoch binding, so a false coordinate
7297        // fails closed rather than mis-attributing.
7298        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7299        let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
7300        if ch.private {
7301            if let Some(k) = ch.key {
7302                coords.push((ch.epoch.0, k));
7303            }
7304            let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
7305            coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
7306        } else {
7307            coords.push((community.root_epoch.0, community.community_root));
7308            let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
7309            coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
7310        }
7311        coords.dedup();
7312
7313        let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
7314        for (epoch, ikm) in coords {
7315            let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
7316            if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
7317                super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
7318            {
7319                if opened.rumor_id.to_hex() == rumor_id_hex {
7320                    let conv: [u8; 32] = group
7321                        .conv_key()
7322                        .as_bytes()
7323                        .try_into()
7324                        .map_err(|_| "conversation key size".to_string())?;
7325                    found = Some((opened, conv));
7326                    break;
7327                }
7328            }
7329        }
7330        let Some((opened, conv_key)) = found else {
7331            return Err("that message is from a key epoch this device no longer holds".to_string());
7332        };
7333
7334        let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
7335            super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
7336            super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
7337            super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
7338        })?;
7339        entries.push(entry);
7340
7341        publish_pin_list(transport, community, &ch, &entries).await
7342    })
7343    .await
7344}
7345
7346/// The deriving secret (ikm) for a channel plane at `epoch` — the same lookup
7347/// `channel_conv_key_at` performs, surfaced for the open path.
7348fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
7349    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7350    if ch.private {
7351        if ch.epoch.0 == epoch {
7352            return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
7353        }
7354        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
7355        crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
7356            .unwrap_or_default()
7357            .into_iter()
7358            .find(|(ep, _)| ep.0 == epoch)
7359            .map(|(_, k)| k)
7360            .ok_or("that message is from a key epoch this device no longer holds".to_string())
7361    } else if community.root_epoch.0 == epoch {
7362        Ok(community.community_root)
7363    } else {
7364        crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
7365            .unwrap_or_default()
7366            .into_iter()
7367            .find(|(ep, _)| ep.0 == epoch)
7368            .map(|(_, k)| k)
7369            .ok_or("that message is from a root epoch this device no longer holds".to_string())
7370    }
7371}
7372
7373/// Unpin a message: the next edition without the entry (§7 — no deletion event).
7374pub async fn unpin_message<T: Transport + ?Sized>(
7375    transport: &T,
7376    community: &CommunityV2,
7377    channel_id: &ChannelId,
7378    rumor_id_hex: &str,
7379) -> Result<(), String> {
7380    let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
7381    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7382    let entries = writable_pin_entries(community, channel_id)?;
7383    let kept: Vec<super::pins::PinEntry> = entries
7384        .into_iter()
7385        .filter(|e| {
7386            super::pins::verify_pin_entry(e, &ch_hex)
7387                .map(|v| v.rumor_id != rumor_id_hex)
7388                // An entry we can't verify is kept: unpin removes exactly the
7389                // named message, never collateral.
7390                .unwrap_or(true)
7391        })
7392        .collect();
7393    publish_pin_list(transport, community, &ch, &kept).await
7394}
7395
7396/// §7 curator duties: converge the Pin List when a pinned message is deleted
7397/// or edited. Spawned fire-and-forget from ingest — a non-curator, a sealed
7398/// list, or an unpinned target all no-op silently; the duty is voluntary.
7399pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
7400    let channel_hex = channel_hex.to_string();
7401    let target = target_rumor_hex.to_string();
7402    crate::db::spawn_bound(async move {
7403        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
7404        let _ = run_pin_duty(&transport, &channel_hex, &target, edit).await;
7405    });
7406}
7407
7408/// The duty body, transport-injected so tests can drive it end to end.
7409///
7410/// The affected author acts at once; every other PIN_MESSAGES holder waits a
7411/// deterministic 5-25s stagger (hashed from (me, target) — no thundering herd
7412/// of racing editions) and re-reads before publishing, so a duty another
7413/// curator already performed dissolves into a no-op.
7414async fn run_pin_duty<T: Transport + ?Sized>(
7415    transport: &T,
7416    channel_hex: &str,
7417    target: &str,
7418    edit: Option<super::stream::OpenedStream>,
7419) -> Result<(), String> {
7420    use crate::community::roles::Permissions;
7421    let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
7422        return Ok(());
7423    };
7424    let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
7425    let Some(community) = crate::db::community::load_community_v2(&cid)? else {
7426        return Ok(());
7427    };
7428    let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
7429
7430    // Cheap pre-checks before any waiting: pinned target, readable list, held bit.
7431    let read = read_channel_pins(&community, &channel_id)?;
7432    if read.sealed {
7433        return Ok(());
7434    }
7435    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
7436        return Ok(());
7437    };
7438    if let Some(ed) = &edit {
7439        // Monotonic: a bundle at or past this revision needs no refresh.
7440        if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
7441            return Ok(());
7442        }
7443    }
7444    let me = me_pk()?;
7445    let me_hex = me.to_hex();
7446    let owner_hex = community.owner()?.to_hex();
7447    let roster = crate::db::community::get_community_roles(&cid_hex)?;
7448    if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
7449        return Ok(());
7450    }
7451
7452    if hit.author != me_hex {
7453        let mut h: u32 = 0;
7454        for b in me_hex.bytes().chain(target.bytes()) {
7455            h = h.wrapping_mul(31).wrapping_add(u32::from(b));
7456        }
7457        tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
7458    }
7459
7460    // Re-read after the stagger: another curator's edition may have landed.
7461    let read = read_channel_pins(&community, &channel_id)?;
7462    if read.sealed {
7463        return Ok(());
7464    }
7465    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
7466        return Ok(());
7467    };
7468    let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
7469
7470    let entries: Vec<super::pins::PinEntry> = match &edit {
7471        // Deletion: the next edition simply omits the entry (§7 — replace-entire).
7472        None => read
7473            .pins
7474            .iter()
7475            .filter(|p| p.rumor_id != target)
7476            .map(|p| p.entry.clone())
7477            .collect(),
7478        // Edit: the same entry, its bundle refreshed to the newest revision.
7479        Some(ed) => {
7480            if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
7481                return Ok(());
7482            }
7483            let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
7484            // The edit sealed under the channel's current plane in the common
7485            // (realtime) case; older epochs are tried like every other read.
7486            let mut bundle = None;
7487            let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
7488            let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
7489            epochs.extend(
7490                crate::db::community::held_epoch_keys(&cid_hex, &scope)
7491                    .unwrap_or_default()
7492                    .into_iter()
7493                    .map(|(ep, _)| ep.0),
7494            );
7495            epochs.dedup();
7496            for epoch in epochs {
7497                let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
7498                if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
7499                    bundle = Some(b);
7500                    break;
7501                }
7502            }
7503            let Some(bundle) = bundle else { return Ok(()) };
7504            read.pins
7505                .iter()
7506                .map(|p| {
7507                    let mut entry = p.entry.clone();
7508                    if p.rumor_id == target {
7509                        entry.edit = Some(bundle.clone());
7510                    }
7511                    entry
7512                })
7513                .collect()
7514        }
7515    };
7516
7517    crate::log_info!(
7518        "[pins] duty {} for target {} in channel {}",
7519        if edit.is_some() { "edit-refresh" } else { "omission" },
7520        &target[..12],
7521        &channel_hex[..12]
7522    );
7523    publish_pin_list(transport, &community, &ch, &entries).await
7524}
7525
7526/// Silent owner-side widening: an Admin role published before PIN_MESSAGES
7527/// existed gains the bit, so delegated admins can curate pins in communities
7528/// founded before this build. One edition, idempotent, converges across owner
7529/// devices (both publish the same widened mask as editions of one entity).
7530pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
7531    transport: &T,
7532    community: &CommunityV2,
7533) -> Result<bool, String> {
7534    use crate::community::roles::{Permissions, RoleScope};
7535    let my_pk = me_pk()?;
7536    if community.owner()? != my_pk {
7537        return Ok(false);
7538    }
7539    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7540    let roles = crate::db::community::get_community_roles(&cid_hex)?;
7541    let Some(role) = roles.roles.iter().find(|r| {
7542        matches!(r.scope, RoleScope::Server)
7543            && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
7544            && !r.permissions.contains(Permissions::PIN_MESSAGES)
7545    }) else {
7546        return Ok(false);
7547    };
7548    let mut widened = role.clone();
7549    widened.permissions.0 |= Permissions::PIN_MESSAGES;
7550    set_role(transport, community, &widened).await?;
7551    Ok(true)
7552}
7553
7554#[cfg(test)]
7555mod tests {
7556    // The legacy (pre-split) Control derivation, still reachable for the
7557    // migration-compat paths under test.
7558    use super::super::derive::control_group_key;
7559    use crate::ClientRelayExt;
7560    use nostr_sdk::prelude::FinalizeEvent;
7561    use super::super::super::transport::memory::MemoryRelay;
7562    use super::*;
7563    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
7564
7565    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
7566    fn account_name(n: u32) -> String {
7567        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
7568        let mut acct = String::from("npub1");
7569        let mut v = n as usize;
7570        for _ in 0..58 {
7571            acct.push(B[v % 32] as char);
7572            v = v / 32 + 7;
7573        }
7574        acct
7575    }
7576
7577    /// One test participant: its identity keys and its isolated account DB dir.
7578    struct Actor {
7579        keys: Keys,
7580        account: String,
7581    }
7582
7583    /// Two participants sharing one relay but isolated per-account DBs — the
7584    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
7585    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
7586    /// the per-account id caches, so account A's community is invisible to B
7587    /// until B legitimately joins.
7588    struct TestBed {
7589        _tmp: tempfile::TempDir,
7590        _guard: std::sync::MutexGuard<'static, ()>,
7591        relay: MemoryRelay,
7592        relays: Vec<String>,
7593    }
7594
7595    impl TestBed {
7596        fn new() -> (TestBed, Actor, Actor) {
7597            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
7598            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7599            crate::db::close_database();
7600            crate::db::clear_id_caches();
7601            let tmp = tempfile::tempdir().unwrap();
7602            crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
7603
7604            let mk = || {
7605                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7606                let account = account_name(n);
7607                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
7608                crate::db::set_current_account(account.clone()).unwrap();
7609                crate::db::init_database(&account).unwrap();
7610                Actor { keys: Keys::generate(), account }
7611            };
7612            let owner = mk();
7613            let member = mk();
7614            let _ = crate::state::take_nostr_client();
7615            let bed = TestBed {
7616                _tmp: tmp,
7617                _guard: guard,
7618                relay: MemoryRelay::new(),
7619                relays: vec!["wss://r".to_string()],
7620            };
7621            (bed, owner, member)
7622        }
7623
7624        /// Become `actor`: swap the account DB + identity, as a real session swap.
7625        /// Bumps the session generation like production `swap_session` does — so any task a
7626        /// prior actor spawned (e.g. the migration finalize) dies at its std::sync::Arc<crate::db::Session> check
7627        /// instead of racing this actor's DB (a cross-test flake that can't happen in prod).
7628        fn swap_to(&self, actor: &Actor) {
7629            crate::db::close_database();
7630            crate::db::set_current_account(actor.account.clone()).unwrap();
7631            crate::db::init_database(&actor.account).unwrap();
7632            crate::db::clear_id_caches();
7633            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
7634            crate::state::set_my_public_key(actor.keys.public_key());
7635        }
7636    }
7637
7638    /// Legacy single-actor helper (the create/send tests below).
7639    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
7640        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7641        crate::db::close_database();
7642        crate::db::clear_id_caches();
7643        let tmp = tempfile::tempdir().unwrap();
7644        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
7645        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7646        let acct = account_name(n);
7647        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
7648        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
7649        crate::db::set_current_account(acct.clone()).unwrap();
7650        crate::db::init_database(&acct).unwrap();
7651        let _ = crate::state::take_nostr_client();
7652        let owner = Keys::generate();
7653        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
7654        crate::state::set_my_public_key(owner.public_key());
7655        (tmp, guard, owner)
7656    }
7657
7658    /// A transport that simulates a session swap landing DURING a fetch await —
7659    /// so a join straddling the fetch sees an invalid session and aborts.
7660    /// Switches to a real second account on the first fetch, then stays out of
7661    /// the way. It BORROWS the relay that built the community — a fresh one
7662    /// would fail verification for want of a control plane, which is not the
7663    /// thing being tested.
7664    struct SwapMidFetch<'a> {
7665        inner: &'a MemoryRelay,
7666        to: String,
7667        armed: std::sync::atomic::AtomicBool,
7668    }
7669
7670    impl<'a> SwapMidFetch<'a> {
7671        fn arm(inner: &'a MemoryRelay) -> Self {
7672            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(88_000);
7673            let to = account_name(N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
7674            std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
7675            Self { inner, to, armed: std::sync::atomic::AtomicBool::new(true) }
7676        }
7677    }
7678
7679    #[async_trait::async_trait]
7680    impl Transport for SwapMidFetch<'_> {
7681        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7682        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7683            self.inner.publish(e, r).await
7684        }
7685        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7686            self.inner.publish_durable(e, r).await
7687        }
7688        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
7689            let out = self.inner.fetch(q, r).await;
7690            if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
7691                crate::db::set_current_account(self.to.clone()).unwrap();
7692                crate::db::init_database(&self.to).unwrap();
7693            }
7694            out
7695        }
7696    }
7697
7698    /// Bumps the session generation on the first `publish_durable` — the rekey
7699    /// crate a private-channel create ships before it writes anything locally.
7700    struct SwapMidPublish {
7701        inner: MemoryRelay,
7702    }
7703    #[async_trait::async_trait]
7704    impl Transport for SwapMidPublish {
7705        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7706        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7707            self.inner.publish(e, r).await
7708        }
7709        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7710            let out = self.inner.publish_durable(e, r).await;
7711            crate::db::close_database();
7712            out
7713        }
7714        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
7715            self.inner.fetch(q, r).await
7716        }
7717    }
7718
7719    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
7720    /// the production `LiveTransport` union (first-responding relay's batch, no
7721    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
7722    /// the only harness that can exercise the revocation-race ordering.
7723    struct FixedFetch {
7724        events: Vec<Event>,
7725    }
7726    #[async_trait::async_trait]
7727    impl Transport for FixedFetch {
7728        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7729        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7730            Ok(())
7731        }
7732        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7733            Ok(())
7734        }
7735        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7736            Ok(self.events.clone())
7737        }
7738    }
7739
7740    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
7741    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
7742    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
7743        let q = Query {
7744            kinds: vec![stream::KIND_WRAP],
7745            p_tags: vec![me.to_hex()],
7746            k_tags: vec!["3313".to_string()],
7747            ..Default::default()
7748        };
7749        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
7750    }
7751
7752    #[tokio::test]
7753    async fn create_persists_and_reloads_a_v2_community() {
7754        let (_tmp, _guard, owner) = init_test_db();
7755        let relay = MemoryRelay::new();
7756        let relays = vec!["wss://r".to_string()];
7757
7758        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
7759        assert!(created.identity.verify());
7760        assert_eq!(created.owner().unwrap(), owner.public_key());
7761        assert_eq!(created.channels.len(), 1);
7762
7763        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
7764        assert_eq!(
7765            crate::db::community::community_protocol(created.id()).unwrap(),
7766            Some(crate::community::ConcordProtocol::V2)
7767        );
7768        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
7769        assert_eq!(loaded.name, "Vectorville");
7770        assert_eq!(loaded.community_root, created.community_root);
7771        assert_eq!(loaded.identity, created.identity);
7772        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
7773        assert!(!loaded.channels[0].private);
7774
7775        // The genesis control editions + the owner Join landed on the relay.
7776        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
7777    }
7778
7779    #[tokio::test]
7780    async fn owner_sends_and_reads_back_a_message() {
7781        let (_tmp, _guard, _owner) = init_test_db();
7782        let relay = MemoryRelay::new();
7783        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
7784        let general = community.channels[0].id;
7785
7786        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
7787        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
7788        assert_ne!(id1, id2);
7789
7790        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
7791        let texts: Vec<String> = page
7792            .iter()
7793            .filter_map(|f| match &f.event {
7794                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7795                _ => None,
7796            })
7797            .collect();
7798        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
7799    }
7800
7801    #[test]
7802    fn send_stamps_never_repeat_within_a_process() {
7803        // Ordering is by the `ms` tag, and an optimized build seals several sends
7804        // per millisecond. Identical stamps fell through to the reader's
7805        // content-derived tiebreak, which knows nothing about which was typed
7806        // first, so a fast sender could watch its own messages come back
7807        // shuffled — the flake in `owner_sends_and_reads_back_a_message` once the
7808        // suite began compiling optimized. Distinctness must not depend on how
7809        // slow the build happens to be.
7810        let stamps: Vec<u64> = (0..5_000).map(|_| next_send_ms()).collect();
7811        for pair in stamps.windows(2) {
7812            assert!(pair[1] > pair[0], "stamps must strictly increase: {} then {}", pair[0], pair[1]);
7813        }
7814    }
7815
7816    #[tokio::test]
7817    async fn same_millisecond_messages_order_by_rumor_id_not_relay_order() {
7818        // Cross-client half: when stamps DO collide (two senders, clock skew),
7819        // every reader must still agree. A bare `ms` sort left ties in whatever
7820        // order the relay served them across pages, so two readers could show the
7821        // same pair in opposite orders. The rumor id is content-derived, so
7822        // pinning ties to it is something every client computes identically.
7823        let (_tmp, _guard, _owner) = init_test_db();
7824        let relay = MemoryRelay::new();
7825        let community = create_community(&relay, "Ties", vec!["wss://r".into()], None).await.unwrap();
7826        let general = community.channels[0].id;
7827
7828        let at = now_ms();
7829        for i in 0..6 {
7830            send_chat_message_at(&relay, &community, &general, &format!("tied {i}"), None, &[], vec![], at)
7831                .await
7832                .unwrap();
7833        }
7834        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
7835        let msgs: Vec<(Vec<u8>, String)> = page
7836            .iter()
7837            .filter_map(|f| match &f.event {
7838                ChatEvent::Message { .. } => {
7839                    let o = f.event.opened();
7840                    Some((o.rumor_id.as_bytes().to_vec(), o.rumor.content.clone()))
7841                }
7842                _ => None,
7843            })
7844            .collect();
7845        assert_eq!(msgs.len(), 6, "all six tied messages read back");
7846        let got: Vec<String> = msgs.iter().map(|(_, c)| c.clone()).collect();
7847        let mut by_id = msgs.clone();
7848        by_id.sort_by(|a, b| a.0.cmp(&b.0));
7849        let expected: Vec<String> = by_id.into_iter().map(|(_, c)| c).collect();
7850        assert_eq!(got, expected, "ties resolve by rumor id, never by the relay's serving order");
7851    }
7852
7853    #[tokio::test]
7854    async fn a_rapid_burst_carries_strictly_increasing_stamps() {
7855        // The call-site half: sends must draw from the monotonic stamp, not the
7856        // raw clock. An optimized build seals several per millisecond, and equal
7857        // stamps hand a sender's own ordering to the content tiebreak, which
7858        // knows nothing about typing order.
7859        let (_tmp, _guard, _owner) = init_test_db();
7860        let relay = MemoryRelay::new();
7861        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
7862        let general = community.channels[0].id;
7863        for i in 0..8 {
7864            send_message(&relay, &community, &general, &format!("msg {i}")).await.unwrap();
7865        }
7866        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
7867        let stamps: Vec<u64> = page
7868            .iter()
7869            .filter_map(|f| match &f.event {
7870                ChatEvent::Message { .. } => Some(f.event.opened().at_ms),
7871                _ => None,
7872            })
7873            .collect();
7874        assert_eq!(stamps.len(), 8);
7875        for pair in stamps.windows(2) {
7876            assert!(pair[1] > pair[0], "a sender's own stamps must strictly increase, got {pair:?}");
7877        }
7878    }
7879
7880    #[tokio::test]
7881    async fn a_second_member_reads_the_public_channel_from_the_root() {
7882        // A member who holds the community_root (via an invite bundle, modeled
7883        // here by cloning the community) reads the owner's public-channel message
7884        // — public channels need no key delivery, they derive from the root.
7885        let (_tmp, _guard, _owner) = init_test_db();
7886        let relay = MemoryRelay::new();
7887        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
7888        let general = community.channels[0].id;
7889        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
7890
7891        // The "member" reconstructs the same read coordinates from the root.
7892        let member_view = community.clone();
7893        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
7894        assert_eq!(page.len(), 1);
7895        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
7896        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
7897    }
7898
7899    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
7900
7901    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
7902        fetch_channel(relay, community, channel, 100)
7903            .await
7904            .unwrap()
7905            .iter()
7906            .filter_map(|f| match &f.event {
7907                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7908                _ => None,
7909            })
7910            .collect()
7911    }
7912
7913    /// History as the BACK-PAGING cursor reads it: every held epoch. Opening a
7914    /// channel deliberately reads only the live planes, so pre-rotation history is
7915    /// reached by paging, which is what a user scrolling up actually does.
7916    async fn all_texts_in<T: crate::community::transport::Transport + ?Sized>(
7917        relay: &T,
7918        community: &CommunityV2,
7919        channel: &ChannelId,
7920    ) -> Vec<String> {
7921        let from_now = now_ms() / 1000 + 3600;
7922        fetch_channel_history(
7923            relay,
7924            community,
7925            channel,
7926            100,
7927            8,
7928            None,
7929            Some(from_now),
7930            crate::community::transport::Evidence::Quorum,
7931            |_| true,
7932        )
7933        .await
7934        .unwrap()
7935        .iter()
7936        .filter_map(|f| match &f.event {
7937            ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7938            _ => None,
7939        })
7940        .collect()
7941    }
7942
7943    #[tokio::test]
7944    async fn direct_invite_full_loop_owner_and_member_converse() {
7945        let (bed, owner, member) = TestBed::new();
7946
7947        // Owner creates a community, posts, and Direct-Invites the member's npub.
7948        bed.swap_to(&owner);
7949        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
7950        let general = community.channels[0].id;
7951        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
7952        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7953
7954        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
7955        bed.swap_to(&member);
7956        assert!(
7957            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7958            "the member does not hold the community before joining"
7959        );
7960        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7961        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7962        assert_eq!(joined.id().0, community.id().0, "joined the same community");
7963        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
7964        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
7965
7966        // The member reads the owner's public-channel history and replies.
7967        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
7968        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
7969
7970        // The owner reads the member's reply.
7971        bed.swap_to(&owner);
7972        assert_eq!(
7973            texts_in(&bed.relay, &community, &general).await,
7974            vec!["owner: welcome!", "member: thanks for the invite"],
7975            "both actors' messages interleave in ms order on the shared channel"
7976        );
7977
7978        // The Guestbook memberlist now folds both participants.
7979        let members = memberlist(&bed.relay, &community).await.unwrap();
7980        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
7981        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
7982        assert_eq!(members.len(), 2);
7983    }
7984
7985    /// Join-time ban gate: an honest client whose npub is on the authorized banlist
7986    /// refuses to join — no Guestbook Join publish, no local write — through the shared
7987    /// accept path every door (direct invite, parked, public link, migration) funnels into.
7988    #[tokio::test]
7989    async fn a_banned_member_is_refused_at_join_time() {
7990        let (bed, owner, member) = TestBed::new();
7991
7992        bed.swap_to(&owner);
7993        let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
7994        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7995        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
7996
7997        bed.swap_to(&member);
7998        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7999        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
8000        assert!(err.contains("banned"), "refusal names the reason: {err}");
8001        assert!(
8002            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
8003            "a refused join persists nothing"
8004        );
8005
8006        // The gate is the LAST word only for banned members: an unbanned bystander with
8007        // the same invite path still joins (the gate doesn't over-refuse).
8008        bed.swap_to(&owner);
8009        set_banlist(&bed.relay, &community, &[]).await.unwrap();
8010        bed.swap_to(&member);
8011        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8012        assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
8013    }
8014
8015    /// End-to-end member migration: a member holding a v1 community folds the owner's
8016    /// migration dissolution, opens `m`, joins the v2 twin (ban-gated), and the flip
8017    /// re-parents the stitched channel rows + stamps the fence — all from the single event.
8018    #[tokio::test]
8019    async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
8020        use crate::community::migration;
8021        let (bed, owner, member) = TestBed::new();
8022
8023        // Owner builds the v2 twin (real, verifiable on the shared relay).
8024        bed.swap_to(&owner);
8025        let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
8026        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
8027        let jm = join_material(&v2);
8028
8029        // The member holds a v1 community owned by the SAME owner identity (the migration
8030        // premise) — construct + save it, and hold its server root.
8031        bed.swap_to(&member);
8032        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8033        let v1_cid = v1.id.to_hex();
8034        v1.owner_attestation = Some({
8035            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8036                .finalize(&owner.keys).unwrap().as_json()
8037        });
8038        crate::db::community::save_community(&v1).unwrap();
8039        let v1_channel = v1.channels[0].id.to_hex();
8040
8041        // The dissolution payload: v2 JoinMaterial sealed under the v1 server root.
8042        let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
8043        let signpost = migration::MigrationSignpost {
8044            v2_community_id: v2_hex.clone(),
8045            owner_xonly: owner.keys.public_key().to_hex(),
8046            owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
8047            relays: bed.relays.clone(),
8048            name: "Guild".into(),
8049            primary_channel: v1_channel.clone(),
8050            root_epoch: 0,
8051        };
8052        let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
8053        crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
8054
8055        // Drive the migration: opens m, joins v2 (ban-gated), flips.
8056        let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
8057        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
8058
8059        // Fence: the v1 community is terminally marked, and the v2 twin is held + joined.
8060        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
8061        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
8062        assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
8063        let _ = v1_channel;
8064
8065        // Idempotent: a second drive is a no-op (already flipped).
8066        assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
8067    }
8068
8069    /// The OWNER wizard end-to-end: build the twin (primary channel reuses the v1 id),
8070    /// seal + publish the carrier, flip the owner. Then a MEMBER holding the v1 community
8071    /// folds the same carrier and stitches — proving the channel-STITCH the earlier test
8072    /// couldn't (that twin had mismatched ids).
8073    #[tokio::test]
8074    async fn owner_wizard_then_member_migrate_and_stitch() {
8075        use crate::community::migration;
8076        let (bed, owner, member) = TestBed::new();
8077        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8078
8079        // Owner holds a v1 community (they created it) with one channel.
8080        bed.swap_to(&owner);
8081        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8082        let v1_cid = v1.id.to_hex();
8083        let v1_channel = v1.channels[0].id.to_hex();
8084        v1.owner_attestation = Some({
8085            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8086                .finalize(&owner.keys).unwrap().as_json()
8087        });
8088        crate::db::community::save_community(&v1).unwrap();
8089
8090        // Run the wizard.
8091        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8092        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
8093            "owner's own client flipped to v2");
8094        // The owner's v1 channel row re-parented to the twin (stitch), because the twin's
8095        // primary channel REUSES the v1 channel id.
8096        assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
8097            "owner channel stitched to v2");
8098
8099        // A MEMBER holding the same v1 community folds the carrier and migrates.
8100        bed.swap_to(&member);
8101        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8102        // The member's v1 community must be the SAME id + root the owner published under.
8103        m_v1.id = v1.id;
8104        m_v1.server_root_key = v1.server_root_key.clone();
8105        m_v1.channels[0].id = v1.channels[0].id;
8106        m_v1.owner_attestation = v1.owner_attestation.clone();
8107        crate::db::community::save_community(&m_v1).unwrap();
8108
8109        // Fold the carrier off the relay: the dissolution arm seals, persists the pointer,
8110        // AND auto-drives the flip — the live one-event member experience, no manual step.
8111        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
8112        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
8113        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
8114            "the FOLD ITSELF flipped the member (auto-drive)");
8115        assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
8116            "member holds the v2 twin");
8117        // A manual re-drive is an idempotent no-op.
8118        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
8119    }
8120
8121    /// The wizard records the twin in the cross-device community list, like every other v2
8122    /// join/create path. Sibling devices normally discover the twin by folding the carrier
8123    /// themselves, but one that no longer holds the v1 community has no carrier to fold, so
8124    /// the list is its only route in.
8125    #[tokio::test]
8126    async fn wizard_publishes_the_twin_to_the_cross_device_list() {
8127        use crate::community::migration;
8128        let (bed, owner, _member) = TestBed::new();
8129        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8130
8131        bed.swap_to(&owner);
8132        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8133        let v1_cid = v1.id.to_hex();
8134        v1.owner_attestation = Some({
8135            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8136                .finalize(&owner.keys).unwrap().as_json()
8137        });
8138        crate::db::community::save_community(&v1).unwrap();
8139
8140        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8141
8142        // The twin is live in the published list, so a fresh/carrier-less device finds it.
8143        let list = fetch_fragments(&bed.relay, &bed.relays).await.unwrap()
8144            .expect("the wizard published a community list").list;
8145        assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
8146        // The v1 community is NOT tombstoned there: a tombstone reads as "you left" and
8147        // `sync_community_list` would tear down a sibling's v1 row before it can fold the
8148        // carrier, stranding it. The local `migrated_to` fence is what stops v1 ghosts.
8149        assert!(
8150            !list.tombstones.iter().any(|t| t.community_id == v1_cid),
8151            "migration must not tombstone the v1 community"
8152        );
8153    }
8154
8155    /// The wizard takes the same per-cid claim the member drive does, so a double-fired
8156    /// command (or the owner's own carrier self-fold racing the wizard's phase 2→3 gap)
8157    /// cannot run two wizards: the second would re-mint a twin before the ledger lands
8158    /// (the double-mint orphan) and race its flip against the first.
8159    #[tokio::test]
8160    async fn wizard_refuses_while_a_drive_holds_the_claim() {
8161        use crate::community::migration;
8162        let (bed, owner, _member) = TestBed::new();
8163        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8164
8165        bed.swap_to(&owner);
8166        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8167        let v1_cid = v1.id.to_hex();
8168        v1.owner_attestation = Some({
8169            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8170                .finalize(&owner.keys).unwrap().as_json()
8171        });
8172        crate::db::community::save_community(&v1).unwrap();
8173
8174        // Simulate the concurrent drive holding the cid (what the live carrier fold does).
8175        migration::test_hold_drive_claim(&v1_cid);
8176        let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
8177        assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
8178        // Refused BEFORE minting: no twin, no ledger, nothing to orphan.
8179        assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
8180        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
8181
8182        // Once the drive releases, the wizard runs normally.
8183        migration::test_release_drive_claim(&v1_cid);
8184        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8185        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
8186    }
8187
8188    /// The flip runs UNDER the twin's follow lock, so it can never straddle a follow
8189    /// worker's whole-row save (which deletes channel rows absent from its pre-flip,
8190    /// channel-less struct — pruning exactly the rows the flip just re-parented).
8191    /// Proves the lock actually serializes rather than being a no-op: with the lock held
8192    /// the wizard cannot reach its flip, and it completes once released.
8193    #[tokio::test]
8194    async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
8195        use crate::community::migration;
8196        let (bed, owner, _member) = TestBed::new();
8197        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8198        // Shared across the spawned wizard, so both halves see the same relay state.
8199        let relay = std::sync::Arc::new(MemoryRelay::new());
8200
8201        bed.swap_to(&owner);
8202        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8203        let v1_cid = v1.id.to_hex();
8204        let v1_channel = v1.channels[0].id.to_hex();
8205        v1.owner_attestation = Some({
8206            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8207                .finalize(&owner.keys).unwrap().as_json()
8208        });
8209        crate::db::community::save_community(&v1).unwrap();
8210
8211        // Phase 1 alone, so the twin's id (and therefore its follow lock) is known before
8212        // the flip runs — exactly what a follow worker would have loaded.
8213        let twin = create_migration_twin(
8214            &*relay, "Guild", bed.relays.clone(), None,
8215            (v1.channels[0].id, "general".to_string()),
8216        ).await.unwrap();
8217        let v2_id = twin.identity.community_id;
8218        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
8219        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
8220
8221        // A follow pass is in flight: it holds the lock across its network stage.
8222        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
8223
8224        let wizard = tokio::spawn({
8225            let relay = relay.clone();
8226            let v1 = v1.clone();
8227            async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
8228        });
8229
8230        // The wizard runs its network phases but must BLOCK at the flip.
8231        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
8232        assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
8233        assert!(
8234            crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
8235            "the fence must not be stamped while the follow lock is held"
8236        );
8237
8238        // The follow pass finishes; the flip proceeds.
8239        drop(held);
8240        let flipped = wizard.await.unwrap().unwrap();
8241        assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
8242        assert_eq!(
8243            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
8244            Some(v2_hex.as_str()),
8245            "the channel row is stitched to the twin, not pruned"
8246        );
8247    }
8248
8249    /// THE LYNCHPIN: a banned-but-never-cut v1 member CAN open `m` (they hold the v1
8250    /// root — no read-cut ever rotated it), but the wizard cloned the v1 banlist onto the
8251    /// twin, so the ban-gated accept refuses them: no Guestbook Join, no flip, room stays
8252    /// sealed. This is the exact residual JSKitty accepted, proven enforced.
8253    #[tokio::test]
8254    async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
8255        use crate::community::migration;
8256        let (bed, owner, banned) = TestBed::new();
8257        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8258
8259        // Owner's v1 community with the member on the BANLIST (never read-cut: epoch 0).
8260        bed.swap_to(&owner);
8261        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8262        let v1_cid = v1.id.to_hex();
8263        v1.owner_attestation = Some({
8264            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8265                .finalize(&owner.keys).unwrap().as_json()
8266        });
8267        crate::db::community::save_community(&v1).unwrap();
8268        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
8269
8270        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8271
8272        // The banned member holds the same v1 (same root — never cut) and folds the carrier.
8273        bed.swap_to(&banned);
8274        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8275        m_v1.id = v1.id;
8276        m_v1.server_root_key = v1.server_root_key.clone();
8277        m_v1.channels[0].id = v1.channels[0].id;
8278        m_v1.owner_attestation = v1.owner_attestation.clone();
8279        crate::db::community::save_community(&m_v1).unwrap();
8280        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
8281
8282        // They hold the pointer AND can open `m` — but the drive is REFUSED at the ban gate.
8283        let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
8284        let payload = migration::parse_migration_payload(&raw).unwrap();
8285        assert!(payload.m.is_some());
8286        let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
8287        assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
8288        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
8289        assert!(
8290            crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
8291            "banned member never acquires the v2 twin"
8292        );
8293    }
8294
8295    /// Wizard resume never double-mints: a re-run after the TWIN_MINTED ledger row exists
8296    /// completes on the SAME v2 identity — with a NON-vacuous phase-1b re-run (a sibling
8297    /// channel + a banlist entry crash-recovered end-to-end, sibling stitched). Plus the
8298    /// crash-heal: flip landed but the FLIPPED ledger write didn't → re-run reports success.
8299    #[tokio::test]
8300    async fn wizard_resume_continues_on_the_same_twin() {
8301        use crate::community::migration;
8302        let (bed, owner, banned) = TestBed::new();
8303        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8304
8305        bed.swap_to(&owner);
8306        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8307        // A second channel + a banned member make the resumed phase-1b tail REAL work.
8308        let mut sibling = v1.channels[0].clone();
8309        sibling.id = crate::community::ChannelId(crate::community::random_32());
8310        sibling.name = "offtopic".into();
8311        v1.channels.push(sibling.clone());
8312        let v1_cid = v1.id.to_hex();
8313        v1.owner_attestation = Some({
8314            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8315                .finalize(&owner.keys).unwrap().as_json()
8316        });
8317        crate::db::community::save_community(&v1).unwrap();
8318        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
8319
8320        // Simulate a crash right after the mint: build the twin + ledger TWIN_MINTED, stop
8321        // BEFORE the sibling channel + banlist clone ever ran.
8322        let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
8323        let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
8324        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
8325
8326        // The re-run resumes onto the SAME identity, re-runs 1b, and completes.
8327        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8328        assert_eq!(v2_hex, minted_hex, "no second twin was minted");
8329        let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
8330        assert_eq!(ledger_v2, minted_hex);
8331        assert_eq!(phase, migration::PHASE_FLIPPED);
8332        // The crash-recovered sibling stitched too, and the banlist clone landed on the wire
8333        // (folding the twin's control plane yields the banned npub).
8334        assert_eq!(
8335            crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
8336            Some(minted_hex.as_str()),
8337            "sibling channel re-parented by the resumed run"
8338        );
8339        let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
8340        let wire_banlist = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
8341            .await
8342            .unwrap()
8343            .banned;
8344        assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
8345            "the resumed banlist clone is folded from the twin's wire control plane");
8346
8347        // Crash-heal: roll the ledger back to CARRIER_PUBLISHED (flip landed, ledger behind)
8348        // → the re-run reports SUCCESS and heals, never "already been migrated".
8349        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
8350        let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8351        assert_eq!(healed, minted_hex);
8352        let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
8353        assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
8354
8355        // Resume past a SELF-SEAL: a fold sealed the community after the carrier but
8356        // before the flip write (dissolved=1, migrated_to still NULL, ledger at
8357        // CARRIER_PUBLISHED). A wizard resume must NOT read this as a foreign dissolution.
8358        // Reuse THIS bed (a second TestBed would re-lock DB_TEST_GUARD and deadlock) with a
8359        // fresh v1 owned by the same owner.
8360        let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
8361        let v1b_cid = v1b.id.to_hex();
8362        v1b.owner_attestation = Some({
8363            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
8364                .finalize(&owner.keys).unwrap().as_json()
8365        });
8366        crate::db::community::save_community(&v1b).unwrap();
8367        let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
8368        let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
8369        crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
8370        crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); // the self-seal
8371        let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
8372        assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
8373        assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
8374    }
8375
8376    /// The birth refound SEEDS the roster: rolling a genesis (epoch 0) twin to epoch 1 with an
8377    /// explicit member list makes those members fold into the memberlist WITHOUT any of them
8378    /// publishing a Join — the anti-ghost-town seed for not-yet-migrated v1 members (who hold
8379    /// no v2 keys). Genesis had no snapshot power; epoch 1 (owner = minting refounder) does.
8380    #[tokio::test]
8381    async fn birth_refound_seeds_an_explicit_roster() {
8382        let (bed, owner, _m) = TestBed::new();
8383        bed.swap_to(&owner);
8384        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
8385            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
8386        assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
8387        // Two strangers who never join — pure seeded members.
8388        let ghost_a = Keys::generate().public_key();
8389        let ghost_b = Keys::generate().public_key();
8390
8391        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
8392        assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
8393
8394        // The memberlist folds all three from the epoch-1 snapshot, though only the owner
8395        // ever published a Join.
8396        let members = memberlist(&bed.relay, &rolled).await.unwrap();
8397        assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
8398        assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
8399
8400        // The compacted control plane still verifies (owner genesis carried to epoch 1) — a
8401        // fresh joiner at epoch 1 folds it. And a genesis-epoch snapshot has NO power: rolling
8402        // a fresh twin's snapshot only counts because the owner minted epoch 1.
8403        let _ = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
8404            .expect("the epoch-1 twin verifies from its compacted control plane");
8405
8406        // RESUME IDEMPOTENCE: a re-call on the already-refounded twin is a no-op (returns
8407        // epoch 1), never a double-advance to epoch 2 — the crash-between-wire-and-ledger case.
8408        let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
8409        assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
8410        assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
8411    }
8412
8413    /// A banned entry in the seed list must NOT wedge the verify-back: fold_members
8414    /// subtracts the banlist, so a banned seed is never "readable" — the defensive filter drops
8415    /// it before the snapshot, so the refound still completes instead of aborting forever.
8416    #[tokio::test]
8417    async fn birth_refound_ignores_a_banned_seed_entry() {
8418        let (bed, owner, _m) = TestBed::new();
8419        bed.swap_to(&owner);
8420        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
8421            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
8422        let good = Keys::generate().public_key();
8423        let banned = Keys::generate();
8424        // Ban `banned` on the twin, then hand refound a seed list that (wrongly) includes them.
8425        set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
8426        let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
8427
8428        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
8429            .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
8430        assert_eq!(rolled.root_epoch, Epoch(1));
8431        let members = memberlist(&bed.relay, &rolled).await.unwrap();
8432        assert!(members.contains(&good), "the non-banned seed lands");
8433        assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
8434    }
8435
8436    /// The "late migrator never misses an epoch" property: a SEEDED-but-never-landed
8437    /// member (in the roster only via the birth snapshot, holding no keys, never posted) is a
8438    /// RECIPIENT of a subsequent OWNER refound — so a rotation that happens before they migrate
8439    /// still mints them a rekey blob to walk forward on. Verified by checking the ghost lands
8440    /// in the refound's memberlist-derived recipient set (they get a base-rekey blob).
8441    #[tokio::test]
8442    async fn a_seeded_member_receives_a_later_refound_rekey() {
8443        let (bed, owner, _m) = TestBed::new();
8444        bed.swap_to(&owner);
8445        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
8446            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
8447        let ghost = Keys::generate();
8448        // Birth refound seeds the ghost (never joins, holds no keys).
8449        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
8450        assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
8451
8452        // A later OWNER refound (epoch 1→2) derives its rekey recipients from memberlist(),
8453        // which folds the snapshot — so the ghost IS a recipient (a base-rekey blob is minted
8454        // for them by construction) AND is re-snapshotted at epoch 2. Surviving in the epoch-2
8455        // memberlist proves both: the refound saw them as a member and carried them forward, so
8456        // a late migrator who opens `m` (epoch 1) can then walk their epoch-2 blob forward.
8457        let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
8458        assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
8459        assert!(
8460            memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
8461            "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
8462        );
8463    }
8464
8465    /// Governance survives migration: a v1 ADMIN is re-granted @admin on the twin (holds
8466    /// MANAGE_ROLES there), while a plain member is not.
8467    #[tokio::test]
8468    async fn v1_admin_stays_admin_across_migration() {
8469        use crate::community::migration;
8470        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
8471        let (bed, owner, admin) = TestBed::new();
8472        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8473
8474        bed.swap_to(&owner);
8475        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8476        let v1_cid = v1.id.to_hex();
8477        v1.owner_attestation = Some({
8478            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8479                .finalize(&owner.keys).unwrap().as_json()
8480        });
8481        crate::db::community::save_community(&v1).unwrap();
8482        // v1 governance: one Admin role, granted to `admin`.
8483        let admin_role = Role::admin("a1".repeat(32));
8484        let roles = CommunityRoles {
8485            roles: vec![admin_role.clone()],
8486            grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
8487        };
8488        crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
8489
8490        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8491        let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
8492
8493        // Fold the twin's authority from the wire: the admin holds MANAGE_ROLES, a stranger doesn't.
8494        let authority = fetch_authority(&bed.relay, &twin).await;
8495        assert!(
8496            authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
8497            "the v1 admin is an admin on the v2 twin"
8498        );
8499        assert!(
8500            !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
8501            "a non-admin gains no authority"
8502        );
8503    }
8504
8505    /// A device that never folded the dissolution tombstone still heals.
8506    ///
8507    /// Live two-device wedge: the control fold is what seals a migrated-away community,
8508    /// and the boot control probe can veto that fold indefinitely (it is `since`-windowed
8509    /// over the CONTROL plane, while the tombstone lives at the DISSOLVED coordinate). The
8510    /// second device therefore sat UNSEALED, which used to exclude it from the sweep
8511    /// (`dissolved = 1`) AND from the flip retry (no pointer) — the one state that most
8512    /// needed probing was the one nothing probed, so it stayed on v1 forever.
8513    #[tokio::test]
8514    async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
8515        use crate::community::migration;
8516        let (bed, owner, member) = TestBed::new();
8517        bed.swap_to(&owner);
8518
8519        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8520        let v1_cid = v1.id.to_hex();
8521        v1.owner_attestation = Some({
8522            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8523                .finalize(&owner.keys).unwrap().as_json()
8524        });
8525        crate::db::community::save_community(&v1).unwrap();
8526
8527        // The owner migrates on their FIRST device: this publishes the carrier tombstone.
8528        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
8529        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
8530
8531        // A MEMBER's device that holds the v1 community and never folded the tombstone:
8532        // unsealed, pointer-less, unchecked — exactly the wedged shape.
8533        bed.swap_to(&member);
8534        crate::db::community::save_community(&v1).unwrap();
8535        assert!(
8536            !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
8537            "precondition: the wedged device has NOT sealed its v1 row"
8538        );
8539
8540        // It IS a sweep candidate now (the fix); before, `dissolved = 1` excluded it.
8541        assert!(
8542            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8543            "an unsealed, migrated-away v1 must be probed"
8544        );
8545
8546        migration::sweep_dissolved_for_migration(&bed.relay).await;
8547
8548        // The sweep found the carrier, sealed the v1 row, and flipped it to the twin.
8549        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
8550        assert_eq!(
8551            crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
8552            Some(v2_hex.as_str()),
8553            "flipped to the same twin the first device produced"
8554        );
8555        assert!(
8556            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8557            "and the sweep converges — no re-probing forever"
8558        );
8559    }
8560
8561    /// The sweep converges on a PLAIN dissolution (owner-signed, no payload) but a
8562    /// non-owner tombstone (member-mintable) must NOT mark it checked — else a partial-relay
8563    /// probe returning only a stranger's record would permanently stop the sweep before the
8564    /// owner's real carrier is ever fetched.
8565    #[tokio::test]
8566    async fn sweep_marks_checked_only_on_an_owner_tombstone() {
8567        use crate::community::migration;
8568        let (bed, owner, stranger) = TestBed::new();
8569
8570        bed.swap_to(&owner);
8571        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8572        let v1_cid = v1.id.to_hex();
8573        v1.owner_attestation = Some({
8574            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
8575                .finalize(&owner.keys).unwrap().as_json()
8576        });
8577        crate::db::community::save_community(&v1).unwrap();
8578
8579        // A STRANGER publishes a (payload-less) tombstone at the dissolved coordinate, and
8580        // the community is locally sealed (as if folded on an old build) but not yet checked.
8581        let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
8582        let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
8583        bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
8584        crate::db::community::set_community_dissolved(&v1_cid).unwrap();
8585
8586        // Sweep: the only record is a stranger's → NOT marked checked (still a candidate).
8587        migration::sweep_dissolved_for_migration(&bed.relay).await;
8588        assert!(
8589            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8590            "a stranger-only probe must not converge the sweep"
8591        );
8592
8593        // Now the OWNER publishes a plain dissolution → sweep marks it checked.
8594        let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
8595        let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
8596        bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
8597        migration::sweep_dissolved_for_migration(&bed.relay).await;
8598        assert!(
8599            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
8600            "an owner plain-dissolution converges the sweep"
8601        );
8602    }
8603
8604    /// Wizard preflight refuses before the timelock and for non-owners.
8605    #[tokio::test]
8606    async fn wizard_preflight_gates_timelock_and_ownership() {
8607        use crate::community::migration;
8608        let (bed, owner, _member) = TestBed::new();
8609        bed.swap_to(&owner);
8610        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
8611        v1.owner_attestation = Some({
8612            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
8613                .finalize(&owner.keys).unwrap().as_json()
8614        });
8615        crate::db::community::save_community(&v1).unwrap();
8616
8617        // Before the unlock → refused, nothing published.
8618        let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
8619        assert!(err.contains("not unlocked"), "{err}");
8620        assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
8621    }
8622
8623    #[tokio::test]
8624    async fn public_link_full_loop() {
8625        let (bed, owner, member) = TestBed::new();
8626
8627        bed.swap_to(&owner);
8628        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
8629        let general = community.channels[0].id;
8630        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
8631        // Mint a shareable link (a non-stock relay so the fragment carries it).
8632        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8633        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
8634        assert!(link.url.contains('#'), "the fragment carries the token");
8635
8636        // Member joins purely from the URL string.
8637        bed.swap_to(&member);
8638        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
8639        assert_eq!(joined.id().0, community.id().0);
8640        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
8641    }
8642
8643    #[test]
8644    fn bundle_of_snapshots_the_held_icon() {
8645        let owner = Keys::generate();
8646        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
8647        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
8648        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
8649        c.icon = Some(icon.clone());
8650        let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
8651        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
8652    }
8653
8654    #[test]
8655    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
8656        // follow_rekeys' fetch fan AND streamauth's plane registration share
8657        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
8658        // MUST include archived roots or an AUTH-gated relay never serves the
8659        // rotation crate → the channel stalls at its old epoch.
8660        let (_tmp, _guard, _owner) = init_test_db();
8661        let cur_root = [9u8; 32];
8662        let cid = crate::community::CommunityId([1u8; 32]);
8663        let cid_hex = cid.to_hex();
8664
8665        // No archives yet → just the current root.
8666        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8667        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
8668
8669        // Archive two prior roots (freshest-first ordering is asserted below).
8670        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
8671        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
8672        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8673        assert_eq!(roots[0], cur_root, "current root leads");
8674        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
8675        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
8676        // Freshest-archived-first (epoch 1 before epoch 0).
8677        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
8678
8679        // A stored root equal to the CURRENT one must not duplicate.
8680        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
8681        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8682        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
8683
8684        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
8685        for e in 3..20u64 {
8686            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
8687        }
8688        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
8689        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
8690    }
8691
8692    #[tokio::test]
8693    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
8694        let (bed, owner, member) = TestBed::new();
8695
8696        bed.swap_to(&owner);
8697        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
8698        // The icon lives on the Control Plane, never in the bundle — publish it
8699        // as a metadata edition so the preview must FOLD to see it.
8700        let icon = control::ImageRef {
8701            url: "https://blossom.example/soap".into(),
8702            key: "k".into(),
8703            nonce: "n".into(),
8704            hash: "h".into(),
8705            extra: Default::default(),
8706        };
8707        let mut meta = community.metadata();
8708        meta.icon = Some(icon.clone());
8709        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8710        // An any-host base — the naddr#fragment payload is domain-agnostic.
8711        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
8712
8713        // A NON-member previews: the real name + the live icon, nothing persisted.
8714        bed.swap_to(&member);
8715        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
8716        assert_eq!(preview.name, "Soapbox");
8717        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
8718        assert!(
8719            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
8720            "previewing must not persist a membership"
8721        );
8722    }
8723
8724    #[tokio::test]
8725    async fn a_previewed_join_reuses_the_verified_fold() {
8726        let (bed, owner, member) = TestBed::new();
8727        bed.swap_to(&owner);
8728        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
8729        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8730
8731        bed.swap_to(&member);
8732        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
8733        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
8734        assert_eq!(joined.id().0, community.id().0);
8735        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
8736        // The slot was CONSUMED by the join — proving the handoff path ran (a
8737        // verify re-walk would have left the preview's entry in place).
8738        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
8739    }
8740
8741    #[tokio::test]
8742    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
8743        let (bed, owner, member) = TestBed::new();
8744        bed.swap_to(&owner);
8745        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
8746        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8747
8748        bed.swap_to(&member);
8749        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
8750
8751        // Seed from zero: the stored fold equals the authoritative live fold.
8752        assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the seed folds fresh events");
8753        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8754        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
8755        assert!(cursor > 0, "the cursor advanced past zero");
8756        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
8757        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
8758        assert_eq!(stored, live, "stored fold == live fold after the seed");
8759        assert!(stored.contains(&member.keys.public_key()));
8760
8761        // Nothing new on the plane → an idle re-sync folds nothing.
8762        assert!(sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty());
8763
8764        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
8765        bed.swap_to(&owner);
8766        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
8767        bed.swap_to(&member);
8768        assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the kick lands incrementally");
8769        assert!(
8770            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
8771            "an owner kick removes the member from the stored fold"
8772        );
8773    }
8774
8775    #[tokio::test]
8776    async fn a_preview_then_revoke_still_refuses_the_join() {
8777        let (bed, owner, member) = TestBed::new();
8778        bed.swap_to(&owner);
8779        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
8780        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8781
8782        // Member previews (warming the verified handoff), THEN the owner revokes.
8783        bed.swap_to(&member);
8784        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
8785        assert_eq!(p.name, "RevokeRace");
8786        bed.swap_to(&owner);
8787        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
8788        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
8789
8790        // The join MUST refuse: the handoff skips only the root re-verify, never
8791        // the bundle re-fetch that carries the revocation gate.
8792        bed.swap_to(&member);
8793        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
8794        assert!(err.contains("revoked"), "got: {err}");
8795    }
8796
8797    #[tokio::test]
8798    async fn a_revoked_link_refuses_to_join() {
8799        let (bed, owner, member) = TestBed::new();
8800        bed.swap_to(&owner);
8801        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
8802        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8803        // Owner retires the link (re-posts the coordinate as a tombstone).
8804        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
8805        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
8806
8807        bed.swap_to(&member);
8808        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
8809        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
8810    }
8811
8812    #[tokio::test]
8813    async fn an_expired_direct_invite_refuses_to_join() {
8814        let (bed, owner, member) = TestBed::new();
8815        bed.swap_to(&owner);
8816        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
8817        // Hand-mint an invite that expired in the past.
8818        let inviter = owner.keys.clone();
8819        let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
8820        bundle.expires_at = Some(1_000); // unix ms, long past
8821        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
8822        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
8823
8824        bed.swap_to(&member);
8825        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8826        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
8827        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
8828    }
8829
8830    #[tokio::test]
8831    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
8832        // The revocation-durability fix: if ANY signer-valid tombstone is among the
8833        // fetched events, refuse — even when a Live bundle is returned FIRST (the
8834        // production union has no newest-first sort, so a stale relay's Live can lead).
8835        let (bed, owner, member) = TestBed::new();
8836        bed.swap_to(&owner);
8837        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
8838        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8839        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
8840
8841        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
8842        // `events.first()` would join the Live; the scan-all fix must refuse.
8843        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
8844
8845        bed.swap_to(&member);
8846        let err = accept_public_link(&union, &link.url).await.unwrap_err();
8847        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
8848    }
8849
8850    #[test]
8851    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
8852        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
8853        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
8854        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
8855        // cap, which trips even earlier — but the count guard is the real defense
8856        // for the single-layer public-link bundle.)
8857        let owner = Keys::generate();
8858        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
8859        let hex = crate::simd::hex::bytes_to_hex_32;
8860        let root = [0x11u8; 32];
8861        let mut bundle = CommunityInvite {
8862            community_id: hex(&identity.community_id.0),
8863            owner: hex(&identity.owner_xonly),
8864            owner_salt: hex(&identity.owner_salt),
8865            community_root: hex(&root),
8866            root_epoch: 0,
8867            control_pk: None,
8868            channels: vec![],
8869            relays: vec!["wss://r".into()],
8870            name: "X".into(),
8871            icon: None,
8872            expires_at: None,
8873            creator_npub: None,
8874            label: None,
8875            extra: Default::default(),
8876        };
8877        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
8878            .map(|i| {
8879                let mut id = [0u8; 32];
8880                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
8881                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
8882            })
8883            .collect();
8884        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
8885    }
8886
8887    #[tokio::test]
8888    async fn a_join_swap_between_fetch_and_save_lands_in_the_joining_account() {
8889        // A public-link accept fetches, then saves. A swap in that window used to
8890        // abort the join outright; the community is now written to the account
8891        // that clicked the link, and the account swapped in gets nothing.
8892        let (bed, owner, member) = TestBed::new();
8893        bed.swap_to(&owner);
8894        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
8895        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8896        bed.swap_to(&member);
8897        let joiner = crate::db::current_session();
8898        let swap_relay = SwapMidFetch::arm(&bed.relay);
8899        accept_public_link(&swap_relay, &link.url).await.unwrap();
8900
8901        assert!(
8902            crate::db::with_session(joiner, async {
8903                crate::db::community::load_community_v2(community.id()).unwrap().is_some()
8904            })
8905            .await,
8906            "the join completed for the account that accepted the link"
8907        );
8908    }
8909
8910    #[tokio::test]
8911    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
8912        // The owner is derived from the self-certifying community_id, so the
8913        // memberlist includes them independent of any Guestbook fetch.
8914        let (_tmp, _guard, owner) = init_test_db();
8915        let relay = MemoryRelay::new();
8916        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
8917        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
8918        // still contains the owner.
8919        let empty = MemoryRelay::new();
8920        let members = memberlist(&empty, &community).await.unwrap();
8921        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
8922    }
8923
8924    #[tokio::test]
8925    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
8926        // The mint path can now produce an expiring invite, and the accept gate
8927        // trips on it (end-to-end through the real service, not a hand-built bundle).
8928        let (bed, owner, member) = TestBed::new();
8929        bed.swap_to(&owner);
8930        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
8931        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
8932            .await
8933            .unwrap();
8934
8935        bed.swap_to(&member);
8936        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8937        assert!(
8938            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
8939            "a minted expiring invite refuses past its deadline"
8940        );
8941    }
8942
8943    #[tokio::test]
8944    async fn a_member_who_leaves_drops_from_the_memberlist() {
8945        let (bed, owner, member) = TestBed::new();
8946        bed.swap_to(&owner);
8947        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
8948        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8949
8950        bed.swap_to(&member);
8951        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8952        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8953        // Let the leave land strictly after the join.
8954        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
8955        leave_community(&bed.relay, &joined).await.unwrap();
8956
8957        bed.swap_to(&owner);
8958        let members = memberlist(&bed.relay, &community).await.unwrap();
8959        assert!(members.contains(&owner.keys.public_key()));
8960        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
8961    }
8962
8963    #[tokio::test]
8964    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
8965        // Multi-account isolation: after the swap, the member's DB holds nothing
8966        // of the owner's community — the dual-stack storage is per-account.
8967        let (bed, owner, member) = TestBed::new();
8968        bed.swap_to(&owner);
8969        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
8970        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
8971
8972        bed.swap_to(&member);
8973        assert!(
8974            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
8975            "the owner's community must be invisible in the member's account DB"
8976        );
8977        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
8978    }
8979
8980    // ── Live control-follow ──────────────────────────────────────────────────
8981
8982    /// Publish an owner-grammar channel edition straight to the control plane,
8983    /// signed by `signer` (the owner for a legit edit, a stranger for the
8984    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
8985    /// The entity's current head `self_hash` on the relay (highest version wins),
8986    /// so a helper can chain a new edition the way a real owner client does.
8987    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
8988        let group = control::ControlPlane::of(&community).write_group().unwrap();
8989        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8990        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
8991        let mut head: Option<(u64, [u8; 32])> = None;
8992        for w in &wraps {
8993            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
8994                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
8995                    head = Some((ed.version, ed.self_hash));
8996                }
8997            }
8998        }
8999        head.map(|(_, h)| h)
9000    }
9001
9002    /// The `vac` a non-owner signer must attach, read off the Grant they were
9003    /// given on the relay (CORD-04 §5). The owner cites nothing. Mirrors what a
9004    /// real client does via `my_authority_citation`, so the fixtures publish the
9005    /// shape Vector actually emits.
9006    async fn cite_on_relay(
9007        relay: &MemoryRelay,
9008        community: &CommunityV2,
9009        signer: &Keys,
9010    ) -> Option<crate::community::edition::AuthorityCitation> {
9011        if community.owner().ok() == Some(signer.public_key()) {
9012            return None;
9013        }
9014        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
9015        let group = control::ControlPlane::of(&community).write_group().unwrap();
9016        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9017        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
9018        let mut head: Option<(u64, [u8; 32])> = None;
9019        for w in &wraps {
9020            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
9021                if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
9022                    head = Some((ed.version, ed.self_hash));
9023                }
9024            }
9025        }
9026        head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
9027    }
9028
9029    async fn publish_channel_edition(
9030        relay: &MemoryRelay,
9031        community: &CommunityV2,
9032        signer: &Keys,
9033        channel_id: &ChannelId,
9034        name: &str,
9035        private: bool,
9036        version: u64,
9037        deleted: bool,
9038    ) {
9039        let group = control::ControlPlane::of(&community).write_group().unwrap();
9040        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
9041        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
9042        let content = serde_json::to_string(&meta).unwrap();
9043        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
9044        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9045        relay.publish(&wrap, &community.relays).await.unwrap();
9046    }
9047
9048    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
9049    /// to the current relay head like a real owner client.
9050    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
9051        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
9052    }
9053
9054    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
9055    /// relay-side newest-first ordering (paging/eviction scenarios).
9056    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
9057        let group = control::ControlPlane::of(&community).write_group().unwrap();
9058        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
9059        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
9060        let content = serde_json::to_string(&meta).unwrap();
9061        let cite = cite_on_relay(relay, community, signer).await;
9062        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());
9063        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
9064        relay.publish(&wrap, &community.relays).await.unwrap();
9065    }
9066
9067    #[test]
9068    fn metadata_apply_captures_undriven_fields_for_republish() {
9069        let owner = Keys::generate();
9070        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
9071        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
9072        let general = held.channels[0].id;
9073
9074        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
9075        let mut custom = serde_json::Map::new();
9076        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
9077        let mut extra = serde_json::Map::new();
9078        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
9079        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
9080        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
9081        assert_eq!(held.meta_custom, Some(custom.clone()));
9082        assert_eq!(held.meta_extra, extra);
9083        // …and the next local edit's base document republishes them verbatim.
9084        assert_eq!(held.metadata().custom, Some(custom));
9085        assert_eq!(held.metadata().extra, held.meta_extra);
9086
9087        // Same contract for a vsk-2 channel head (voice included).
9088        let mut ch_custom = serde_json::Map::new();
9089        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
9090        let ch_meta = control::ChannelMetadata {
9091            name: "general".into(),
9092            private: false,
9093            voice: Some(true),
9094            deleted: None,
9095            custom: Some(ch_custom.clone()),
9096            extra: Default::default(),
9097        };
9098        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
9099        let ch = held.channel(&general).unwrap();
9100        assert_eq!(ch.voice, Some(true));
9101        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
9102        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
9103        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
9104        assert_eq!(rename.custom, Some(ch_custom));
9105    }
9106
9107    #[test]
9108    fn community_metadata_apply_sets_and_clears_images() {
9109        let owner = Keys::generate();
9110        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
9111        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
9112
9113        let icon = control::ImageRef {
9114            url: "https://blossom.example/i".into(),
9115            key: "k".into(),
9116            nonce: "n".into(),
9117            hash: "h".into(),
9118            extra: Default::default(),
9119        };
9120        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
9121        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
9122        assert_eq!(held.icon.as_ref(), Some(&icon));
9123
9124        // An edition is the FULL document: a head without the icon removes it.
9125        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
9126        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
9127        assert_eq!(held.icon, None);
9128    }
9129
9130    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
9131    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
9132        let group = control::ControlPlane::of(&community).write_group().unwrap();
9133        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
9134        let prev = head_hash_on_relay(relay, community, &role_id).await;
9135        let content = crate::community::v2::roles::role_content_json(role).unwrap();
9136        let cite = cite_on_relay(relay, community, signer).await;
9137        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
9138        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9139        relay.publish(&wrap, &community.relays).await.unwrap();
9140    }
9141
9142    /// Hand-rotate a community view to `(new_root, epoch)`, minting the fresh
9143    /// split pair a real base rotation would (CORD-06 §3) — the control_root is
9144    /// the new root itself, which is fine for a test.
9145    fn rotate_view(c: &CommunityV2, new_root: [u8; 32], epoch: u64) -> CommunityV2 {
9146        let mut r = c.clone();
9147        r.community_root = new_root;
9148        r.root_epoch = Epoch(epoch);
9149        r.control_root = Some(new_root);
9150        r.control_pk = Some(crate::community::v2::derive::control_signer_group_key(&new_root, r.id(), Epoch(epoch)).pk());
9151        r
9152    }
9153
9154    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
9155    /// Like a compliant granter it delivers the staff write key inside the Grant
9156    /// (CORD-04 §3) whenever the community view holds one — the recipient's
9157    /// derive check drops it when it isn't owed, so attaching unconditionally
9158    /// keeps the helper simple while exercising the real delivery pipeline.
9159    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
9160        let group = control::ControlPlane::of(&community).write_group().unwrap();
9161        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
9162        let prev = head_hash_on_relay(relay, community, &eid).await;
9163        let grant = MemberGrant { member: member.to_hex(), role_ids };
9164        let control_wrap = community.control_root.filter(|_| community.control_pk.is_some()).map(|cr| {
9165            let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(signer.secret_key(), member).unwrap();
9166            let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(community.root_epoch, &cr).as_bytes()).unwrap();
9167            base64_simd::STANDARD.encode_to_string(&payload)
9168        });
9169        let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, control_wrap).unwrap();
9170        let cite = cite_on_relay(relay, community, signer).await;
9171        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
9172        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9173        relay.publish(&wrap, &community.relays).await.unwrap();
9174    }
9175
9176    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
9177    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
9178        let group = control::ControlPlane::of(&community).write_group().unwrap();
9179        let eid = crate::community::v2::derive::banlist_locator(community.id());
9180        let prev = head_hash_on_relay(relay, community, &eid).await;
9181        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
9182        let cite = cite_on_relay(relay, community, signer).await;
9183        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
9184        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
9185        relay.publish(&wrap, &community.relays).await.unwrap();
9186    }
9187
9188    fn admin_role(role_id: &str, perms: u64) -> Role {
9189        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
9190    }
9191
9192    /// Hand-roll a LEGACY (pre-split) community exactly as a pre-0.4.2 build
9193    /// created one: the whole Control Plane keyed by the community_root
9194    /// derivation, no control pair anywhere. Publishes the two genesis-shaped
9195    /// editions at the LEGACY address, persists, and folds to seed floors.
9196    async fn create_legacy_community(relay: &MemoryRelay, owner: &Keys, name: &str, relays: Vec<String>) -> CommunityV2 {
9197        let identity = control::CommunityIdentity::mint(&owner.public_key());
9198        let root = crate::community::random_32();
9199        let group = control_group_key(&root, &identity.community_id, Epoch(0));
9200        let meta = control::CommunityMetadata { name: name.into(), relays: relays.clone(), ..Default::default() };
9201        let meta_rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &identity.community_id.0, 1, None, &serde_json::to_string(&meta).unwrap(), 1_000, None);
9202        let ch_id = ChannelId(crate::community::random_32());
9203        let ch = control::ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
9204        let ch_rumor = control::build_edition_rumor(owner.public_key(), vsk::CHANNEL_METADATA, &ch_id.0, 1, None, &serde_json::to_string(&ch).unwrap(), 1_000, None);
9205        for r in [&meta_rumor, &ch_rumor] {
9206            let (w, _) = control::seal_control_edition(r, &group, owner, Timestamp::from_secs(1_000)).unwrap();
9207            relay.publish(&w, &relays).await.unwrap();
9208        }
9209        let community = CommunityV2 {
9210            identity,
9211            community_root: root,
9212            root_epoch: Epoch(0),
9213            control_pk: None,
9214            control_root: None,
9215            name: name.into(),
9216            description: None,
9217            icon: None,
9218            banner: None,
9219            meta_custom: None,
9220            meta_extra: Default::default(),
9221            relays,
9222            channels: vec![ChannelV2 { id: ch_id, name: "general".into(), private: false, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() }],
9223            dissolved: false,
9224            created_at_ms: 1_000_000,
9225        };
9226        crate::db::community::save_community_v2(&community).unwrap();
9227        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9228        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &root);
9229        let _ = follow_control(relay, &community).await;
9230        crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
9231    }
9232
9233    #[tokio::test]
9234    async fn a_refounding_mints_the_split_and_delivers_the_pair_by_staffness() {
9235        // CORD-06 §1/§3 end to end: the rotation mints a fresh control pair, a
9236        // STAFF recipient's blob carries the secret (136), a plain member's only
9237        // the address (104), the compaction publishes ONLY at the new split
9238        // address, and each follower adopts exactly what its blob delivered.
9239        let (bed, owner, admin) = TestBed::new();
9240        bed.swap_to(&owner);
9241        let community = create_community(&bed.relay, "SplitRefound", bed.relays.clone(), None).await.unwrap();
9242        assert!(community.control_pk.is_some() && community.control_root.is_some(), "genesis mints the split");
9243
9244        // A staff admin (granted; the Grant delivers the wrap) and a plain
9245        // member (Guestbook Join only — a recipient with no rank).
9246        let rid = "d1".repeat(32);
9247        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9248        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
9249        let plain = Keys::generate();
9250        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
9251        let join = guestbook::build_join_rumor(plain.public_key(), None, 1_000);
9252        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &plain, Timestamp::from_secs(1_000)).unwrap();
9253        bed.relay.publish(&w, &community.relays).await.unwrap();
9254
9255        // The admin joins and folds — adopting the write key from their Grant.
9256        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9257        bed.swap_to(&admin);
9258        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9259        assert_eq!(joined.control_pk, community.control_pk, "the bundle handed over the address");
9260        assert_eq!(joined.control_root, None, "a bundle never carries the secret");
9261        let _ = follow_control(&bed.relay, &joined).await;
9262        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
9263        assert_eq!(joined.control_root, community.control_root, "the Grant's control_wrap delivered the secret");
9264
9265        // The owner refounds: a FRESH pair beside the new root.
9266        bed.swap_to(&owner);
9267        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9268        assert_eq!(refounded.root_epoch, Epoch(1));
9269        assert!(refounded.control_pk.is_some() && refounded.control_root.is_some());
9270        assert_ne!(refounded.control_pk, community.control_pk, "the pair rolls with the root");
9271
9272        // Blob forms by staff-ness: the admin's carries the secret, the plain
9273        // member's only the pk (CORD-06 §1).
9274        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
9275        let chunks = fetch_rekey_chunks(&bed.relay, &community.relays, &base_group).await.unwrap();
9276        let rotation = &rekey::collect_rotations(&chunks)[0];
9277        async fn open_as(rotation: &rekey::Rotation, community_id: &crate::community::CommunityId, keys: &Keys) -> rekey::BaseKeyDelivery {
9278            let blob = rekey::find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), &keys.public_key().to_bytes(), rotation.scope, rotation.new_epoch).expect("a blob for every recipient");
9279            let signer = crate::signer::ActiveSigner::Keys(keys.clone());
9280            rekey::open_base_blob(&signer, &rotation.rotator, community_id, Epoch(1), blob).await.unwrap()
9281        }
9282        let admin_delivery = open_as(rotation, community.id(), &admin.keys).await;
9283        assert_eq!(admin_delivery.control_root, refounded.control_root, "staff blob = 136, secret included");
9284        let plain_delivery = open_as(rotation, community.id(), &plain).await;
9285        assert_eq!(plain_delivery.control_pk.map(|p| PublicKey::from_slice(p.as_slice()).unwrap()), refounded.control_pk);
9286        assert_eq!(plain_delivery.control_root, None, "member blob = 104, never the secret");
9287
9288        // The compaction lives ONLY at the new split address — never mirrored to
9289        // the new epoch's legacy-derived address (CORD-06 §3).
9290        let legacy_new = control_group_key(&refounded.community_root, community.id(), Epoch(1));
9291        let q = |pk: String| Query { kinds: vec![stream::KIND_WRAP], authors: vec![pk], limit: Some(50), ..Default::default() };
9292        assert!(bed.relay.fetch(&q(legacy_new.pk_hex()), &community.relays).await.unwrap().is_empty(), "no legacy mirror");
9293        assert!(!bed.relay.fetch(&q(refounded.control_pk.unwrap().to_hex()), &community.relays).await.unwrap().is_empty(), "the compaction rides the split address");
9294
9295        // The admin's follow adopts the whole pair from their 136-byte blob.
9296        bed.swap_to(&admin);
9297        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
9298        let adopted = follow.updated.expect("the admin adopts the rotation");
9299        assert_eq!(adopted.root_epoch, Epoch(1));
9300        assert_eq!(adopted.control_pk, refounded.control_pk);
9301        assert_eq!(adopted.control_root, refounded.control_root, "staff crossing a rotation get the new secret in the blob");
9302    }
9303
9304    #[tokio::test]
9305    async fn revoking_the_last_link_privatizes_and_rotates_but_an_earlier_revoke_does_not() {
9306        // CORD-06 §3: converting a Public Community to Private is a Refounding
9307        // trigger — revoking stops NEW acquisitions, only the rotation cuts off
9308        // everyone who already fetched the bundle. v1 does this; v2 shipped
9309        // without it. Revoking a link while ANOTHER stays live is not the
9310        // conversion, and must not burn an epoch.
9311        let (_tmp, _guard, _owner) = init_test_db();
9312        let relay = MemoryRelay::new();
9313        let community = create_community(&relay, "Privatize", vec!["wss://r".into()], None).await.unwrap();
9314        assert_eq!(community.root_epoch, Epoch(0));
9315
9316        let a = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9317        let b = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9318        assert!(community_is_public(&relay, &community).await, "two live links = Public");
9319
9320        // Revoking ONE of two: still Public, still epoch 0 — no rotation.
9321        revoke_public_link(&relay, &community, &crate::simd::hex::bytes_to_hex_16(&a.token)).await.unwrap();
9322        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9323        assert_eq!(held.root_epoch, Epoch(0), "a link remains live — not the Public→Private conversion");
9324        assert!(community_is_public(&relay, &held).await, "still Public");
9325
9326        // Revoking the LAST one privatizes AND rotates.
9327        revoke_public_link(&relay, &held, &crate::simd::hex::bytes_to_hex_16(&b.token)).await.unwrap();
9328        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9329        assert!(!community_is_public(&relay, &held).await, "no live links = Private");
9330        assert_eq!(held.root_epoch, Epoch(1), "privatizing rotated the base key");
9331        // The rotation is a real one: the new epoch carries the split pair and
9332        // the old root is retired, so a link-joined lurker's key is now dead.
9333        assert!(held.control_pk.is_some(), "the privatize rotation mints the split like any base rotation");
9334        assert_ne!(held.community_root, community.community_root, "the base key actually rolled");
9335    }
9336
9337    #[tokio::test]
9338    async fn a_legacy_community_upgrades_as_a_side_effect_of_its_next_refounding() {
9339        // CORD-06 §3: a compliant Rotator performing ANY base rotation MUST mint
9340        // the split — a pre-split community upgrades with nobody deciding to.
9341        let (_tmp, _guard, owner) = init_test_db();
9342        let relay = MemoryRelay::new();
9343        let community = create_legacy_community(&relay, &owner, "Legacy", vec!["wss://r".into()]).await;
9344        assert!(community.control_pk.is_none(), "starts pre-split");
9345
9346        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
9347        assert_eq!(refounded.root_epoch, Epoch(1));
9348        assert!(refounded.control_pk.is_some() && refounded.control_root.is_some(), "the rotation minted the split");
9349        // And the persisted row agrees (the pair survives a reload).
9350        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9351        assert_eq!(reloaded.control_pk, refounded.control_pk);
9352        assert_eq!(reloaded.control_root, refounded.control_root);
9353    }
9354
9355    #[tokio::test]
9356    async fn an_old_build_deletion_recovers_from_the_stale_bundle_across_the_split() {
9357        // The 0.4.0/0.4.1 upgrade path: those builds hit the widened base blob,
9358        // conclude a false removal, and DELETE the community locally. Recovery on
9359        // upgrade is the Community-List resurrection: re-accept the STALE-epoch
9360        // material (the old plane is still served), then walk the rekey chain
9361        // forward — now parsing the 104-byte form — to the current epoch.
9362        let (bed, owner, member) = TestBed::new();
9363        bed.swap_to(&owner);
9364        let community = create_legacy_community(&bed.relay, &owner.keys, "Recover", bed.relays.clone()).await;
9365        let stale_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9366
9367        // The member joins at the legacy epoch 0.
9368        bed.swap_to(&member);
9369        let joined = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
9370        assert!(joined.control_pk.is_none());
9371
9372        // The owner bans nobody but rotates (any refound upgrades to the split).
9373        bed.swap_to(&owner);
9374        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9375
9376        // The member's OLD build falsely self-removes and deletes the community.
9377        bed.swap_to(&member);
9378        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9379        crate::db::community::delete_community(&cid_hex).unwrap();
9380        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
9381
9382        // Upgrade-recovery: re-accept the stale epoch-0 material (what
9383        // sync_community_list does from the surviving 13302 entry)...
9384        let readopted = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
9385        assert_eq!(readopted.root_epoch, Epoch(0), "re-anchored at the stale epoch");
9386        // ...then the walk crosses the split rotation and adopts the pair.
9387        let follow = follow_rekeys(&bed.relay, &readopted, &crate::db::current_session()).await.unwrap();
9388        assert!(!follow.self_removed, "never a false removal on this build");
9389        let healed = follow.updated.expect("the walk adopts the split rotation");
9390        assert_eq!(healed.root_epoch, Epoch(1));
9391        assert_eq!(healed.control_pk, refounded.control_pk, "the member reads the upgraded plane");
9392        assert_eq!(healed.control_root, None, "a plain member never receives the secret");
9393        // And the upgraded control plane folds for them (the compacted state).
9394        let folded = follow_control(&bed.relay, &healed).await;
9395        assert!(folded.is_ok(), "the recovered member folds the split plane: {folded:?}");
9396    }
9397
9398    #[tokio::test]
9399    async fn an_unopenable_blob_at_my_locator_never_concludes_removal() {
9400        // CORD-06 §2: removal = NO blob for my locator across a complete
9401        // rotation. A blob that EXISTS but won't open (a future width, a rotator
9402        // bug) must park — turning it into a self-removal is exactly how the
9403        // pre-split builds deleted communities on the first widened rotation.
9404        let (bed, owner, member) = TestBed::new();
9405        bed.swap_to(&owner);
9406        let community = create_community(&bed.relay, "Garbled", bed.relays.clone(), None).await.unwrap();
9407        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9408        bed.swap_to(&member);
9409        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9410
9411        // The owner mints a complete, authorized rotation whose blob AT THE
9412        // MEMBER'S LOCATOR is garbage (undecryptable), owner's own blob real.
9413        bed.swap_to(&owner);
9414        let new_epoch = Epoch(1);
9415        let new_root = [0x4Du8; 32];
9416        let control_root = [0x4Eu8; 32];
9417        let control_pk = super::super::derive::control_signer_group_key(&control_root, community.id(), new_epoch).pk();
9418        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
9419        let garbled = rekey::RekeyBlob {
9420            locator: rekey::blob_locator(&owner.keys.public_key().to_bytes(), &member.keys.public_key().to_bytes(), RekeyScope::Root, new_epoch),
9421            wrapped: "bm90LWEtcmVhbC1uaXA0NC1wYXlsb2Fk".into(),
9422        };
9423        let own = rekey::build_base_blob(
9424            &crate::signer::ActiveSigner::Keys(owner.keys.clone()),
9425            &owner.keys.public_key().to_bytes(),
9426            &owner.keys.public_key(),
9427            new_epoch,
9428            &new_root,
9429            &control_pk.to_bytes(),
9430            Some(&control_root),
9431        )
9432        .await
9433        .unwrap();
9434        let base_group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
9435        let chunks = rekey::build_rekey_chunks_local(&owner.keys, &base_group, RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[garbled, own], 2_000, None).unwrap();
9436        for c in &chunks {
9437            bed.relay.publish(c, &community.relays).await.unwrap();
9438        }
9439
9440        // The member's follow PARKS: no adoption, and NEVER a removal.
9441        bed.swap_to(&member);
9442        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
9443        assert!(!follow.self_removed, "an unopenable blob at my locator is not an exclusion");
9444        assert!(follow.updated.is_none(), "nothing adopted from an unreadable blob");
9445        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "the community survives");
9446        // …and the park is LEGIBLE. A silent park is indistinguishable from "no
9447        // rotation happened", which is what makes an undecryptable blob planted
9448        // at a protected member's locator an abusable severing tool.
9449        assert!(follow.wedged, "an unopenable key delivery must be surfaced, not swallowed");
9450    }
9451
9452    #[tokio::test]
9453    async fn a_control_wrap_is_refused_on_a_stale_epoch_or_a_non_deriving_secret() {
9454        // What the "no rank-gating on adoption" argument rests on (CORD-04 §3):
9455        // a wrap is adopted only if its secret derives to the control_pk held
9456        // for the epoch named INSIDE its ciphertext. That derive check is the
9457        // load-bearing gate (revert-verified); the explicit epoch comparison
9458        // beside it is defense in depth and can't be isolated, since deriving
9459        // at a stale epoch already misses the held address. Compaction re-wraps
9460        // a Grant head verbatim across Refoundings, so stale wraps are a
9461        // structural certainty, not an edge case.
9462        let (bed, owner, member) = TestBed::new();
9463        bed.swap_to(&owner);
9464        let community = create_community(&bed.relay, "AdoptGate", bed.relays.clone(), None).await.unwrap();
9465        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
9466        bed.swap_to(&member);
9467        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9468        assert!(joined.control_root.is_none());
9469
9470        // Build a Grant edition carrying `control_wrap` with a chosen plaintext.
9471        let wrap_for = |epoch: Epoch, secret: &[u8; 32]| {
9472            let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(owner.keys.secret_key(), &member.keys.public_key()).unwrap();
9473            let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(epoch, secret).as_bytes()).unwrap();
9474            base64_simd::STANDARD.encode_to_string(&ct)
9475        };
9476        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.keys.public_key().to_bytes());
9477        let ed_with = |wrap: String| {
9478            let grant = MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![] };
9479            let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, Some(wrap)).unwrap();
9480            let rumor = control::build_edition_rumor(owner.keys.public_key(), vsk::GRANT, &eid, 1, None, &content, 1_000, None);
9481            control::parse_edition_rumor(&rumor).unwrap()
9482        };
9483
9484        // (a) A wrap for a DIFFERENT epoch — the real secret, wrong epoch.
9485        let real = community.control_root.unwrap();
9486        let stale = ed_with(wrap_for(Epoch(joined.root_epoch.0 + 1), &real));
9487        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&stale)).await, None, "a stale-epoch wrap is refused");
9488
9489        // (b) Right epoch, a secret that does NOT derive to the held pk.
9490        let forged = ed_with(wrap_for(joined.root_epoch, &[0x66u8; 32]));
9491        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&forged)).await, None, "a non-deriving secret is refused");
9492
9493        // (c) The real secret at the held epoch IS adopted — the gates above
9494        // reject on their own merits, not because adoption is broken.
9495        let good = ed_with(wrap_for(joined.root_epoch, &real));
9496        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&good)).await, Some(real), "the true wrap adopts");
9497    }
9498
9499    #[test]
9500    fn the_vault_carries_the_split_and_the_twin_strips_the_secret() {
9501        // CORD-02 §8: the list carries every key its holder has — the address
9502        // for all, the secret for staff. The migration `m` (member-bound) MUST
9503        // strip the secret.
9504        let owner = Keys::generate();
9505        let g = control::genesis(&owner, control::CommunityMetadata { name: "Vault".into(), ..Default::default() }, 1_000).unwrap();
9506        let c = CommunityV2::from_genesis(&g, "Vault", None, vec!["wss://r".into()], 0);
9507        let jm = join_material(&c);
9508        assert_eq!(jm.control_pk, c.control_pk.map(|p| p.to_hex()));
9509        assert_eq!(jm.control_root, Some(crate::simd::hex::bytes_to_hex_32(&g.control_root)));
9510        let twin = twin_join_material(&c);
9511        assert_eq!(twin.control_pk, jm.control_pk, "members get the address");
9512        assert_eq!(twin.control_root, None, "members never get the secret");
9513        let bundle = material_to_invite(&jm);
9514        assert_eq!(bundle.control_pk, jm.control_pk, "a rehydrating bundle carries the address");
9515    }
9516
9517    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
9518    // any control edition) must not be able to SUPPRESS a role or grant by forging a
9519    // higher version at its coordinate. Owner-only signers mask this entirely, so every
9520    // attacker below signs as a NON-owner member.
9521
9522    #[tokio::test]
9523    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
9524        let (bed, owner, attacker) = TestBed::new();
9525        bed.swap_to(&owner);
9526        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
9527        let victim = Keys::generate().public_key();
9528        grant_admin(&bed.relay, &community, &victim).await.unwrap();
9529
9530        // The admin role sits at a deterministic, publicly-computable coordinate.
9531        let admin_rid = fetch_authority(&bed.relay, &community)
9532            .await
9533            .roles
9534            .roles
9535            .iter()
9536            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
9537            .unwrap()
9538            .role_id
9539            .clone();
9540        // Attacker forges v2 of that exact role, stripping its powers.
9541        publish_role(
9542            &bed.relay,
9543            &community,
9544            &attacker.keys,
9545            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
9546            2,
9547        )
9548        .await;
9549
9550        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
9551        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
9552        assert!(
9553            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
9554            "the floor advances only to the AUTHORIZED head (owner v1)"
9555        );
9556        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
9557    }
9558
9559    #[tokio::test]
9560    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
9561        let (bed, owner, attacker) = TestBed::new();
9562        bed.swap_to(&owner);
9563        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
9564        let victim = Keys::generate();
9565        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
9566
9567        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
9568        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
9569
9570        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
9571        assert!(
9572            authority.roles.is_admin(&victim.public_key().to_hex()),
9573            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
9574        );
9575    }
9576
9577    #[tokio::test]
9578    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
9579        let (bed, owner, attacker) = TestBed::new();
9580        bed.swap_to(&owner);
9581        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
9582        let victim = Keys::generate().public_key();
9583        grant_admin(&bed.relay, &community, &victim).await.unwrap();
9584
9585        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
9586        // unauthorized, so the post-authorize cap never sees them.
9587        for i in 0u8..6 {
9588            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
9589            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9590        }
9591
9592        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
9593        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
9594        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
9595    }
9596
9597    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
9598    /// roster + banlist — two clients converge iff these match.
9599    fn authority_fingerprint(a: &AuthoritySet) -> String {
9600        let mut roles = a.roles.roles.clone();
9601        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
9602        let mut grants = a.roles.grants.clone();
9603        for g in &mut grants {
9604            g.role_ids.sort();
9605        }
9606        grants.sort_by(|x, y| x.member.cmp(&y.member));
9607        let banned: Vec<&String> = a.banned.iter().collect();
9608        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
9609    }
9610
9611    #[tokio::test]
9612    async fn the_v2_authority_fold_is_order_independent() {
9613        // THE core consensus property: two honest clients that receive the SAME
9614        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
9615        // authorized roster + banlist (author-aware select_authorized + banlist
9616        // fold + cap, all deterministic). A divergence here would fork the
9617        // community's moderation state between honest members.
9618        let (bed, owner, _a) = TestBed::new();
9619        bed.swap_to(&owner);
9620        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
9621
9622        // A rich control plane: two admins, an extra role, two grants (one of them a
9623        // grant to a member the owner then bans), a banlist, a rename, a channel.
9624        let admin1 = Keys::generate().public_key();
9625        let admin2 = Keys::generate().public_key();
9626        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
9627        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
9628        let mod_rid = "5c".repeat(32);
9629        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
9630        let member = Keys::generate().public_key();
9631        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
9632        let banned_member = Keys::generate().public_key();
9633        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
9634        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
9635        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
9636        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
9637        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
9638
9639        let editions = fetch_control(&bed.relay, &community).await;
9640        let floors = load_floors(&community);
9641        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
9642
9643        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
9644
9645        // Fold under many arrival permutations: reversed, and several deterministic
9646        // rotations/interleavings. Every one must match the baseline.
9647        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
9648        let mut rev = editions.clone();
9649        rev.reverse();
9650        orders.push(rev);
9651        for shift in [1usize, 3, 5, 7] {
9652            let n = editions.len();
9653            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
9654        }
9655        // A deterministic "shuffle": interleave from both ends.
9656        let mut zip = Vec::with_capacity(editions.len());
9657        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
9658        while lo <= hi {
9659            zip.push(editions[lo as usize].clone());
9660            if lo != hi {
9661                zip.push(editions[hi as usize].clone());
9662            }
9663            lo += 1;
9664            hi -= 1;
9665        }
9666        orders.push(zip);
9667
9668        for (i, order) in orders.iter().enumerate() {
9669            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
9670            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
9671        }
9672        // Sanity: the fingerprint reflects real state (the banned member is out, the
9673        // honest admins are in).
9674        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
9675        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
9676    }
9677
9678    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
9679    struct FetchErrors(MemoryRelay);
9680    #[async_trait::async_trait]
9681    impl crate::community::transport::Transport for FetchErrors {
9682        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
9683        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9684            self.0.publish(e, r).await
9685        }
9686        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9687            Err("relay down".to_string())
9688        }
9689        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9690            self.0.publish_durable(e, r).await
9691        }
9692    }
9693
9694    #[tokio::test]
9695    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
9696        let (bed, owner, victim) = TestBed::new();
9697        bed.swap_to(&owner);
9698        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
9699        let victim_hex = victim.keys.public_key().to_hex();
9700        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
9701        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9702        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
9703
9704        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
9705        // return an empty banlist (which would silently un-ban on withheld data).
9706        let down = FetchErrors(MemoryRelay::new());
9707        let view = fetch_authority(&down, &community).await;
9708        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
9709    }
9710
9711    #[tokio::test]
9712    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
9713        let (bed, owner, _m) = TestBed::new();
9714        bed.swap_to(&owner);
9715        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
9716        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9717        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
9718        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
9719
9720        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
9721        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9722        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
9723        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
9724        follow_control(&bed.relay, &community).await.unwrap();
9725        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
9726
9727        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
9728        // drops both admins yet raises no gap. The completeness gate must RETAIN the
9729        // stored roster rather than persist the lossy one.
9730        let relay2 = MemoryRelay::new();
9731        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
9732        follow_control(&relay2, &community).await.unwrap();
9733        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
9734        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
9735    }
9736
9737    #[tokio::test]
9738    async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
9739        // CORD-04 §5 covers EVERY control entity, not just the delegation chain.
9740        // Vector already gated roles and grants in-fold; metadata, channels and
9741        // the banlist resolved on permission alone, so a client one sweep behind
9742        // honored an edit from an admin whose demotion it had not read yet.
9743        let (_tmp, _guard, owner) = init_test_db();
9744        let relay = MemoryRelay::new();
9745        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
9746        let admin = Keys::generate();
9747        let rid = "a7".repeat(32);
9748        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
9749        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
9750
9751        // The admin acts WITHOUT citing (what every pre-citation client emitted).
9752        let group = control::ControlPlane::of(&community).write_group().unwrap();
9753        let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
9754        let rumor = control::build_edition_rumor(
9755            admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
9756            head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
9757            &serde_json::to_string(&meta).unwrap(), 1_000, None,
9758        );
9759        let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
9760        relay.publish(&wrap, &community.relays).await.unwrap();
9761
9762        let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
9763        let victim = Keys::generate().public_key().to_hex();
9764        let ban_rumor = control::build_edition_rumor(
9765            admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
9766            &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
9767        );
9768        let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
9769        relay.publish(&ban_wrap, &community.relays).await.unwrap();
9770
9771        let updated = follow_control(&relay, &community).await.unwrap();
9772        assert!(
9773            updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
9774            "an uncited metadata edit must not be honored",
9775        );
9776        let authority = fetch_authority(&relay, &community).await;
9777        assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
9778        // The positive case (this same admin, citing, lands) is
9779        // `an_authorized_admin_edits_metadata_but_a_demoted_one_cannot` — its
9780        // helper cites, so it proves the gate is the CITATION and not the
9781        // permission. Re-proving it here would need a fresh chain anyway: a
9782        // cited edition chaining onto the rejected one above is gapped, not
9783        // refused.
9784    }
9785
9786    #[tokio::test]
9787    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
9788        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
9789        // owner revokes the grant, the (now unauthorized) admin's further edit drops
9790        // and the name holds at the last authorized state.
9791        let (_tmp, _guard, owner) = init_test_db();
9792        let relay = MemoryRelay::new();
9793        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
9794        let admin = Keys::generate();
9795        let rid = "a1".repeat(32);
9796        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9797        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9798        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
9799
9800        let updated = follow_control(&relay, &community).await.unwrap().expect("admin edit authorized");
9801        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
9802
9803        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
9804        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
9805        let _ = follow_control(&relay, &community).await.unwrap();
9806        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9807        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
9808    }
9809
9810    #[tokio::test]
9811    async fn a_roleless_member_cannot_edit_metadata() {
9812        let (_tmp, _guard, _owner) = init_test_db();
9813        let relay = MemoryRelay::new();
9814        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
9815        let stranger = Keys::generate();
9816        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
9817        assert!(
9818            follow_control(&relay, &community).await.unwrap().is_none(),
9819            "a roleless member's metadata edit never folds"
9820        );
9821    }
9822
9823    #[tokio::test]
9824    async fn a_self_signed_grant_is_not_authority() {
9825        // The self-promotion defense: a member self-signs both a role and a grant of
9826        // it to themselves. authorize_delegation drops both (their signer never traces
9827        // to the owner), so their metadata edit stays unauthorized.
9828        let (_tmp, _guard, _owner) = init_test_db();
9829        let relay = MemoryRelay::new();
9830        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
9831        let rogue = Keys::generate();
9832        let rid = "b2".repeat(32);
9833        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9834        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
9835        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
9836        assert!(
9837            follow_control(&relay, &community).await.unwrap().is_none(),
9838            "a self-signed grant confers no authority"
9839        );
9840    }
9841
9842    #[tokio::test]
9843    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
9844        let (_tmp, _guard, owner) = init_test_db();
9845        let relay = MemoryRelay::new();
9846        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
9847        let target = "cc".repeat(32);
9848
9849        // A non-BAN-holder's banlist edition is folded but NOT enforced.
9850        let rogue = Keys::generate();
9851        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
9852        let floors = load_floors(&community);
9853        let editions = fetch_control(&relay, &community).await;
9854        let authority = fold_authority(&community, &editions, &floors);
9855        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
9856
9857        // The owner (supreme, holds BAN) bans the target: now enforced.
9858        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
9859        let editions = fetch_control(&relay, &community).await;
9860        let authority = fold_authority(&community, &editions, &floors);
9861        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
9862    }
9863
9864    #[tokio::test]
9865    async fn a_banned_admin_loses_all_authority() {
9866        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
9867        // banned admin's authority is dropped and their edits refused.
9868        let (_tmp, _guard, owner) = init_test_db();
9869        let relay = MemoryRelay::new();
9870        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
9871        let admin = Keys::generate();
9872        let rid = "e5".repeat(32);
9873        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9874        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9875        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
9876        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
9877
9878        assert!(
9879            follow_control(&relay, &community).await.unwrap().is_none(),
9880            "a banned admin's edit is dropped even with an unstripped grant"
9881        );
9882        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
9883        assert!(authority.banned.contains(&admin.public_key().to_hex()));
9884        assert!(
9885            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9886            "a banned admin holds no bit"
9887        );
9888    }
9889
9890    #[tokio::test]
9891    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
9892        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
9893        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
9894        // and NOT the owner (supreme, unbannable).
9895        let (_tmp, _guard, owner) = init_test_db();
9896        let relay = MemoryRelay::new();
9897        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
9898        let admin = Keys::generate();
9899        let moder = Keys::generate();
9900        let stranger = Keys::generate();
9901        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
9902        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;
9903        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;
9904        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
9905        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
9906        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
9907
9908        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
9909        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
9910        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
9911        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
9912    }
9913
9914    #[tokio::test]
9915    async fn an_unauthorized_higher_banlist_cannot_unban() {
9916        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
9917        // must not erase the owner's ban (author-aware head selection + persisted
9918        // banlist retention).
9919        let (_tmp, _guard, owner) = init_test_db();
9920        let relay = MemoryRelay::new();
9921        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
9922        let target = "cc".repeat(32);
9923        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
9924        follow_control(&relay, &community).await.unwrap(); // persists the ban
9925
9926        let rogue = Keys::generate();
9927        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
9928        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
9929        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
9930    }
9931
9932    #[tokio::test]
9933    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
9934        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
9935        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
9936        let (_tmp, _guard, _owner) = init_test_db();
9937        let relay = MemoryRelay::new();
9938        let relays = vec!["wss://r".to_string()];
9939        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
9940        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
9941        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
9942
9943        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
9944        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
9945        assert_eq!(rehydrated[0].id().0, community.id().0);
9946        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
9947    }
9948
9949    #[tokio::test]
9950    async fn a_retried_leave_tombstone_cannot_bury_a_rejoin_made_meanwhile() {
9951        let (_tmp, _guard, _owner) = init_test_db();
9952        let relay = MemoryRelay::new();
9953        let relays = vec!["wss://r".to_string()];
9954        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
9955        let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9956
9957        // The leave stamps its own removal time, and its publish fails.
9958        let left_at = now_ms() - 1000;
9959        tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
9960        // The user rejoins before the retry gets through.
9961        republish_community_list(&relay, Some(community.id())).await.unwrap();
9962        assert!(fetch_fragments(&relay, &relays).await.unwrap().unwrap().list.is_live(&cid));
9963
9964        // The retry lands late, carrying the ORIGINAL stamp — which the rejoin outranks.
9965        tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
9966        let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
9967        assert!(set.list.is_live(&cid), "a retry re-stamping `now` would have buried the rejoin");
9968    }
9969
9970    #[tokio::test]
9971    async fn a_boot_with_no_fragments_seeds_the_list_from_local_state() {
9972        let (_tmp, _guard, _owner) = init_test_db();
9973        let relay = MemoryRelay::new();
9974        let relays = vec!["wss://r".to_string()];
9975        let community = create_community(&relay, "Seeded", relays.clone(), None).await.unwrap();
9976
9977        // An account arriving from the retired single-event list: held locally, nothing
9978        // at the fragment coordinate. Boot alone has to write it — no membership changes.
9979        let bare = MemoryRelay::new();
9980        sync_community_list(&bare, &relays).await.unwrap();
9981        let set = fetch_fragments(&bare, &relays).await.unwrap().expect("boot seeded the List");
9982        let live = set.list.live_entries();
9983        assert_eq!(live.len(), 1);
9984        assert_eq!(live[0].community_id, crate::simd::hex::bytes_to_hex_32(&community.id().0));
9985
9986        // Latched: a LATER empty read is a relay that lost the fragments, not a fresh
9987        // account, and republishing local state there would bury a sibling's tombstones.
9988        let empty = MemoryRelay::new();
9989        sync_community_list(&empty, &relays).await.unwrap();
9990        assert_eq!(empty.stored_count(), 0, "the seed runs once");
9991    }
9992
9993    #[tokio::test]
9994    async fn a_dissolved_invite_retires_on_accept_and_tombstones_the_list() {
9995        // The invite to a dissolved community is the one join failure with no
9996        // retry: the refusal is an owner-signed grave. The boundary must retire
9997        // the parked row AND write the §8 tombstone siblings purge on.
9998        let (bed, owner, member) = TestBed::new();
9999        bed.swap_to(&owner);
10000        let community = create_community(&bed.relay, "Grave", bed.relays.clone(), None).await.unwrap();
10001        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10002        dissolve_community(&bed.relay, &community).await.unwrap();
10003
10004        bed.swap_to(&member);
10005        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10006        crate::db::community::save_pending_invite(&cid_hex, &bundle, "inviter", i64::MAX).unwrap();
10007        let err = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap_err();
10008        assert_eq!(err, ERR_DISSOLVED, "the grave refusal is the sentinel the boundary matches");
10009
10010        retire_dead_invite(&bed.relay, &cid_hex, &bed.relays).await;
10011        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the dead invite leaves this device");
10012        let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the tombstone published");
10013        assert!(set.list.tombstones.iter().any(|t| t.community_id == cid_hex), "and siblings get the §8 tombstone");
10014        assert!(!set.list.is_live(&cid_hex));
10015    }
10016
10017    #[tokio::test]
10018    async fn an_owner_dissolve_tombstones_the_list_for_sibling_devices() {
10019        // Dissolving IS leaving. The grave alone reaches every MEMBER, but only a §8
10020        // tombstone reaches the owner's own sibling devices — without it they keep the
10021        // membership LIVE in the List, fold the grave, and show a sealed husk the owner
10022        // already deleted, on every device, until each is cleaned up by hand.
10023        let (bed, owner, _member) = TestBed::new();
10024        bed.swap_to(&owner);
10025        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
10026        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10027        let before = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("create published the List");
10028        assert!(before.list.is_live(&cid_hex), "the membership is live before the dissolve");
10029
10030        dissolve_community(&bed.relay, &community).await.unwrap();
10031
10032        let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the List outlives the dissolve");
10033        assert!(
10034            set.list.tombstones.iter().any(|t| t.community_id == cid_hex),
10035            "the dissolve published the §8 tombstone alongside the grave"
10036        );
10037        assert!(!set.list.is_live(&cid_hex), "sibling devices tear the husk down on their next list sync");
10038    }
10039
10040    #[tokio::test]
10041    async fn a_sibling_devices_list_tombstone_purges_the_parked_invite() {
10042        // Device A declined (or retired a dead invite): its §8 tombstone must
10043        // clear device B's parked copy on the next list sync — and ONLY when the
10044        // removal post-dates the invite, so a fresh re-invite survives an old leave.
10045        let (_tmp, _guard, _owner) = init_test_db();
10046        let relay = MemoryRelay::new();
10047        let relays = vec!["wss://r".to_string()];
10048        let cid = crate::community::CommunityId([0x5A; 32]);
10049        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
10050
10051        // An OLD tombstone (a past leave) must not kill an invite received after it.
10052        tombstone_community_list(&relay, &cid, &relays, now_ms() - 600_000).await.unwrap();
10053        crate::db::community::save_pending_invite(&cid_hex, "{}", "inviter", i64::MAX).unwrap();
10054        sync_community_list(&relay, &relays).await.unwrap();
10055        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_some(), "a re-invite supersedes a past leave");
10056
10057        // A tombstone NEWER than the invite is a verdict on it: purged on sync.
10058        tombstone_community_list(&relay, &cid, &relays, now_ms() + 10_000).await.unwrap();
10059        sync_community_list(&relay, &relays).await.unwrap();
10060        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the sibling's removal clears the parked copy");
10061    }
10062
10063    #[tokio::test]
10064    async fn an_unchanged_republish_rewrites_no_fragment() {
10065        // Nothing changed since the last write: the rebuilt fragment serializes to
10066        // the bytes already on the relay, so the write must not touch the wire —
10067        // a bumped created_at on identical content is pure churn.
10068        let (_tmp, _guard, _owner) = init_test_db();
10069        let relay = MemoryRelay::new();
10070        let relays = vec!["wss://r".to_string()];
10071        create_community(&relay, "Stable", relays.clone(), None).await.unwrap();
10072        let before = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
10073
10074        republish_community_list(&relay, None).await.unwrap();
10075        let after = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
10076        assert_eq!(before, after, "a byte-identical fragment must not republish");
10077    }
10078
10079    #[tokio::test]
10080    async fn a_frags_tie_at_equal_age_resolves_to_the_larger_count() {
10081        // A torn repack's worst case: two fragments at the SAME created_at
10082        // disagreeing on the total. The larger count must govern (CORD-02 §8) —
10083        // the smaller would push index 2 out of range and its memberships dormant.
10084        let (_tmp, _guard, owner) = init_test_db();
10085        let relay = MemoryRelay::new();
10086        let relays = vec!["wss://r".to_string()];
10087        let at = now_ms() / 1000;
10088        let two = super::super::list_frag::FragList { frags: 2, entries: vec![], tombstones: vec![], extra: Default::default() };
10089        let three = super::super::list_frag::FragList { frags: 3, entries: vec![], tombstones: vec![], extra: Default::default() };
10090        // The larger count sits at the LOWER index: an age-only rule that keeps
10091        // the last fragment seen would pick 2 here, not 3.
10092        for (frag, index) in [(&three, 0usize), (&two, 1usize)] {
10093            let e = super::super::list_frag::build_fragment_event_keys(&owner, frag, index, at).unwrap();
10094            relay.publish(&e, &relays).await.unwrap();
10095        }
10096        let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
10097        assert_eq!(set.declared, 3, "an age tie resolves to the larger frags");
10098        assert!(!set.is_complete(), "index 2 is unread, so the set refuses to write");
10099    }
10100
10101    /// Aggregate fetches answer from the healthy relay; asked alone, the dead
10102    /// relay errors — the partial-failure shape a boot read can't see through.
10103    struct HalfDeaf<'a>(&'a MemoryRelay, String);
10104    #[async_trait::async_trait]
10105    impl Transport for HalfDeaf<'_> {
10106        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
10107            self.0.publish(e, r).await
10108        }
10109        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
10110            if relays.len() == 1 && relays[0] == self.1 {
10111                return Err("relay did not answer the fetch".to_string());
10112            }
10113            self.0.fetch(query, relays).await
10114        }
10115        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
10116            self.0.fetch_plane(plane, query, relays).await
10117        }
10118        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
10119            self.0.publish_durable(e, r).await
10120        }
10121    }
10122
10123    #[tokio::test]
10124    async fn an_unconfirmed_empty_read_never_seeds() {
10125        // One of the account's relays never answers. The aggregate read comes back
10126        // empty (the healthy relay has nothing), which is indistinguishable from a
10127        // fresh account — but the dead relay may hold a sibling's tombstones, so
10128        // seeding here would bury them. The seed must defer, unlatched, and land
10129        // once every relay confirms empty.
10130        let (_tmp, _guard, _owner) = init_test_db();
10131        let relay = MemoryRelay::new();
10132        let relays = vec!["wss://r".to_string(), "wss://dead".to_string()];
10133        let community = create_community(&relay, "Gated", relays.clone(), None).await.unwrap();
10134        let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10135
10136        let bare = MemoryRelay::new();
10137        let half_deaf = HalfDeaf(&bare, "wss://dead".to_string());
10138        sync_community_list(&half_deaf, &relays).await.unwrap();
10139        assert_eq!(bare.stored_count(), 0, "a failed read must never seed");
10140        assert!(
10141            crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_none(),
10142            "a deferred seed must not latch"
10143        );
10144
10145        // The relay comes back: every relay now confirms empty, and the seed lands.
10146        sync_community_list(&bare, &relays).await.unwrap();
10147        let set = fetch_fragments(&bare, &relays).await.unwrap().expect("the confirmed-empty boot seeds");
10148        assert!(set.list.is_live(&cid));
10149        assert!(crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_some());
10150    }
10151
10152    #[tokio::test]
10153    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
10154        let (_tmp, _guard, _owner) = init_test_db();
10155        let relay = MemoryRelay::new();
10156        let relays = vec!["wss://r".to_string()];
10157        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
10158        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
10159
10160        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
10161        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
10162    }
10163
10164    #[tokio::test]
10165    async fn accepting_the_same_bundle_twice_is_idempotent() {
10166        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
10167        // again must upsert cleanly — same community_id, no duplicate channels, no
10168        // corruption, the keys unchanged.
10169        let (bed, owner, member) = TestBed::new();
10170        bed.swap_to(&owner);
10171        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
10172        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
10173        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10174        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10175
10176        bed.swap_to(&member);
10177        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
10178        let channels_after_first = first.channels.len();
10179        let root_after_first = first.community_root;
10180
10181        // Accept the identical bundle again (restart / redelivery).
10182        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
10183        assert_eq!(second.id().0, first.id().0, "same community_id");
10184        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
10185        assert_eq!(second.community_root, root_after_first, "root unchanged");
10186
10187        // The persisted state is a single clean community with the expected channels.
10188        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10189        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
10190        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
10191    }
10192
10193    #[tokio::test]
10194    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
10195        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
10196        // severs a member; the owner then unbans + sends a FRESH invite carrying the
10197        // NEW root; the member rejoins at the new epoch and converses again. Proves
10198        // a ban is reversible end-to-end, not a one-way door.
10199        let (bed, owner, member) = TestBed::new();
10200        bed.swap_to(&owner);
10201        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
10202        let general = community.channels[0].id;
10203        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10204        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
10205
10206        bed.swap_to(&member);
10207        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10208        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
10209        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
10210
10211        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
10212        bed.swap_to(&owner);
10213        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
10214        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
10215        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
10216        assert_eq!(community.root_epoch, Epoch(1));
10217        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
10218
10219        // The member's follow concludes severance (no blob at the new epoch).
10220        bed.swap_to(&member);
10221        let session = crate::db::current_session();
10222        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
10223
10224        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
10225        // directly, so the test picks the NEW invite unambiguously rather than an
10226        // arbitrary one of the two pending 3313s).
10227        bed.swap_to(&owner);
10228        set_banlist(&bed.relay, &community, &[]).await.unwrap();
10229        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10230        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
10231        let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10232
10233        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
10234        bed.swap_to(&member);
10235        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
10236        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
10237        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
10238        let seen = texts_in(&bed.relay, &rejoined, &general).await;
10239        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
10240        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
10241
10242        bed.swap_to(&owner);
10243        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10244        assert!(
10245            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
10246            "the re-admitted member converses again at the new epoch"
10247        );
10248        // And they're back in the memberlist.
10249        let members = memberlist(&bed.relay, &community).await.unwrap();
10250        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
10251    }
10252
10253    #[tokio::test]
10254    async fn dissolution_blocks_a_join() {
10255        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
10256        // refuses to join.
10257        let (bed, owner, member) = TestBed::new();
10258        bed.swap_to(&owner);
10259        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
10260        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10261        let bundle_json = serde_json::to_string(&bundle).unwrap();
10262        dissolve_community(&bed.relay, &community).await.unwrap();
10263        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
10264
10265        bed.swap_to(&member);
10266        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
10267        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
10268    }
10269
10270    #[tokio::test]
10271    async fn dissolution_seals_writes_but_not_reads() {
10272        // CORD-02 §9: sealed means NO further activity, ever. Reads must survive —
10273        // the history stays browsable, and only explicit user intent deletes it.
10274        let (bed, owner, _member) = TestBed::new();
10275        bed.swap_to(&owner);
10276        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
10277        let general = community.channels[0].id;
10278        send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
10279
10280        dissolve_community(&bed.relay, &community).await.unwrap();
10281        let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10282
10283        for err in [
10284            send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
10285            send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
10286                .await
10287                .unwrap_err(),
10288            send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
10289        ] {
10290            assert!(err.contains("dissolved"), "every write is refused, got: {err}");
10291        }
10292        assert!(
10293            texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
10294            "but the history still reads"
10295        );
10296    }
10297
10298    #[tokio::test]
10299    async fn only_the_owner_can_dissolve() {
10300        let (bed, owner, member) = TestBed::new();
10301        bed.swap_to(&owner);
10302        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
10303        bed.swap_to(&member);
10304        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
10305        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
10306    }
10307
10308    #[tokio::test]
10309    async fn a_foreign_tombstone_is_not_death() {
10310        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
10311        // owner-gated), so the community is not treated as dead.
10312        let (_tmp, _guard, _owner) = init_test_db();
10313        let relay = MemoryRelay::new();
10314        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
10315        let rogue = Keys::generate();
10316        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
10317        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
10318        relay.publish(&wrap, &community.relays).await.unwrap();
10319        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
10320    }
10321
10322    #[tokio::test]
10323    async fn a_public_channel_reads_history_across_a_refounding() {
10324        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
10325        // pre-rotation messages stay readable (the prior epoch's root is archived and
10326        // the read fans out across held epochs).
10327        let (_tmp, _guard, _owner) = init_test_db();
10328        let relay = MemoryRelay::new();
10329        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
10330        let general = community.channels[0].id;
10331        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
10332
10333        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
10334        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
10335        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
10336
10337        let open = texts_in(&relay, &refounded, &general).await;
10338        assert!(open.contains(&"after the refounding".to_string()), "opening reads the live epoch");
10339        assert!(
10340            !open.contains(&"before the refounding".to_string()),
10341            "and NOT the rotated-past one — that plane is the back-paging cursor's job"
10342        );
10343        let paged = all_texts_in(&relay, &refounded, &general).await;
10344        assert!(paged.contains(&"before the refounding".to_string()), "the epoch-0 message stays reachable by paging");
10345        assert!(paged.contains(&"after the refounding".to_string()), "alongside the epoch-1 one");
10346    }
10347
10348    #[tokio::test]
10349    async fn refounding_aborts_when_control_state_is_withheld() {
10350        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
10351        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
10352        // member at the new epoch a fresh joiner bootstraps).
10353        let (_tmp, _guard, owner) = init_test_db();
10354        let relay = MemoryRelay::new();
10355        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
10356        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
10357        follow_control(&relay, &community).await.unwrap(); // seed the banlist floor
10358
10359        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
10360        // reloads fresh state) fetches none of the committed heads.
10361        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10362        moved.relays = vec!["wss://empty".into()];
10363        crate::db::community::save_community_v2(&moved).unwrap();
10364
10365        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
10366        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
10367        assert_eq!(
10368            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
10369            Epoch(0),
10370            "the epoch did NOT advance (zero published state)"
10371        );
10372    }
10373
10374    #[tokio::test]
10375    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
10376        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
10377        // epoch advances, and the removed member's rekey-follow concludes they're cut.
10378        let (bed, owner, member) = TestBed::new();
10379        bed.swap_to(&owner);
10380        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
10381        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10382        let bundle_json = serde_json::to_string(&bundle).unwrap();
10383        bed.swap_to(&member);
10384        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10385
10386        bed.swap_to(&owner);
10387        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
10388        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
10389        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
10390        // The owner still reads the compacted control plane at the new epoch.
10391        assert_eq!(
10392            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
10393            Epoch(1),
10394            "the owner committed the new epoch"
10395        );
10396
10397        // The removed member, following rekeys, is severed (no blob in the rotation).
10398        // Guard captured AFTER the swap: it must belong to the ACTING account (the harness
10399        // swap now bumps the generation exactly like a production swap_session).
10400        bed.swap_to(&member);
10401        let session = crate::db::current_session();
10402        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10403        assert!(follow.self_removed, "the removed member is cut by the re-founding");
10404    }
10405
10406    #[tokio::test]
10407    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
10408        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
10409        // non-owner admin granted BAN CAN re-found (and every member follows it —
10410        // see the receive-side test), but the "strictly outrank every removed
10411        // target" rule still holds: they can't use it to evict the owner.
10412        let (bed, owner, member) = TestBed::new();
10413        bed.swap_to(&owner);
10414        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
10415        let rid = "b0".repeat(32);
10416        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10417        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
10418        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10419        let bundle_json = serde_json::to_string(&bundle).unwrap();
10420        bed.swap_to(&member);
10421        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10422        // Fold the roster so this member's own DB reflects their BAN grant (the
10423        // authority check reads the folded Roster, not the bundle).
10424        let _ = follow_control(&bed.relay, &joined).await;
10425        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10426        // Can't evict the owner (no one outranks the owner).
10427        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
10428        // But CAN re-found removing a plain member they outrank (here, nobody).
10429        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
10430    }
10431
10432    #[tokio::test]
10433    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
10434        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
10435        // follow it — owner-only receive silently strands members whose community
10436        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
10437        // BAN", checked against the folded Roster).
10438        let (bed, owner, me) = TestBed::new();
10439        let admin = Keys::generate();
10440        bed.swap_to(&owner);
10441        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
10442        let rid = "b0".repeat(32);
10443        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10444        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
10445
10446        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
10447        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10448        let bundle_json = serde_json::to_string(&bundle).unwrap();
10449        bed.swap_to(&me);
10450        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10451        let _ = follow_control(&bed.relay, &joined).await;
10452        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10453
10454        // The admin re-founds keeping the owner + me — the owner must always be a
10455        // recipient of a non-owner Refounding.
10456        let new_root = [0xC7; 32];
10457        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
10458
10459        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
10460            .expect("an authorized admin's Refounding is adopted");
10461        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
10462        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
10463    }
10464
10465    #[tokio::test]
10466    async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
10467        // CORD-05 §2: a link shared once keeps working across rotations, because
10468        // its bundle is re-posted behind the same URL. The Refounder can only
10469        // refresh the bundles they hold signer secrets for — their OWN — so
10470        // every other creator has to heal their links when they ADOPT the
10471        // rotation. Without that, an admin's links keep vending the superseded
10472        // root and drop new joiners onto a dead epoch, which is precisely the
10473        // stranding the stable-URL refresh exists to prevent.
10474        let (bed, owner, me) = TestBed::new();
10475        bed.swap_to(&owner);
10476        let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
10477        let rid = "b1".repeat(32);
10478        // CREATE_INVITE too: minting is offer-gated by the same bit readers use.
10479        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
10480        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
10481
10482        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10483        let bundle_json = serde_json::to_string(&bundle).unwrap();
10484        bed.swap_to(&me);
10485        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10486        let _ = follow_control(&bed.relay, &joined).await;
10487        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10488
10489        // I mint a link of my own at the CURRENT epoch.
10490        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
10491        let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
10492        assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
10493
10494        // The OWNER re-founds. Their refresh can't touch my bundle: only I hold
10495        // its signer secret.
10496        let new_root = [0xD4; 32];
10497        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
10498
10499        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
10500            .expect("the owner's Refounding is adopted");
10501        assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
10502
10503        let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
10504        assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
10505        assert_eq!(
10506            crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
10507            new_root,
10508            "and the new root behind the same URL",
10509        );
10510    }
10511
10512    #[tokio::test]
10513    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
10514        // Authority escalation: a BAN-admin can't use a Refounding to evict the
10515        // OWNER (no one outranks the owner). Excluding them makes the rotation
10516        // inadmissible — members fork-reject it rather than migrate to the coup.
10517        let (bed, owner, me) = TestBed::new();
10518        let admin = Keys::generate();
10519        bed.swap_to(&owner);
10520        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
10521        let rid = "b0".repeat(32);
10522        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10523        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
10524
10525        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10526        let bundle_json = serde_json::to_string(&bundle).unwrap();
10527        bed.swap_to(&me);
10528        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10529        let _ = follow_control(&bed.relay, &joined).await;
10530        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10531
10532        // The admin re-founds delivering to me but NOT the owner — a takeover.
10533        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
10534        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10535        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
10536    }
10537
10538    #[tokio::test]
10539    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
10540        // Authority escalation: two equal-rank BAN-admins — neither strictly
10541        // outranks the other, so one can't Refound the other out. Excluding a
10542        // peer makes the rotation inadmissible.
10543        let (bed, owner, me) = TestBed::new();
10544        let admin_a = Keys::generate();
10545        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
10546        bed.swap_to(&owner);
10547        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
10548        let rid = "b0".repeat(32);
10549        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10550        // Both A and B hold the SAME role (same position 1) → peers.
10551        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
10552        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
10553
10554        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10555        let bundle_json = serde_json::to_string(&bundle).unwrap();
10556        bed.swap_to(&me);
10557        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10558        let _ = follow_control(&bed.relay, &joined).await;
10559        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10560
10561        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
10562        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
10563
10564        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10565        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
10566    }
10567
10568    #[tokio::test]
10569    async fn a_retried_refounding_reuses_the_same_root() {
10570        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
10571        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
10572        let (_tmp, _guard, _owner) = init_test_db();
10573        let relay = MemoryRelay::new();
10574        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
10575        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10576        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
10577        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
10578        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
10579    }
10580
10581    #[tokio::test]
10582    async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
10583        // CORD-04 §2 rank inversion. Minting at a position you outrank is
10584        // necessary but NOT sufficient: an edition replaces the entity, so a
10585        // gate that only reads the NEW position lets an admin at position 5
10586        // rewrite the position-1 role to position 9. Every check passes (9 is
10587        // beneath them), and the role that outranked them — plus everyone
10588        // holding it — is now beneath them.
10589        let (bed, owner, attacker) = TestBed::new();
10590        bed.swap_to(&owner);
10591        let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
10592
10593        // A senior role at position 1, and a mid role at position 5 the attacker holds.
10594        let senior = "a1".repeat(32);
10595        let mid = "a5".repeat(32);
10596        publish_role(&bed.relay, &community, &owner.keys,
10597            &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
10598        publish_role(&bed.relay, &community, &owner.keys,
10599            &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
10600        publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
10601
10602        // The attacker republishes the SENIOR role, dropping it beneath themselves.
10603        publish_role(&bed.relay, &community, &attacker.keys,
10604            &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
10605
10606        let authority = fetch_authority(&bed.relay, &community).await;
10607        let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
10608        assert_eq!(
10609            folded_senior.position, 1,
10610            "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
10611        );
10612    }
10613
10614    #[tokio::test]
10615    async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
10616        // CORD-04 §5. Armada's reader REQUIRES this on every non-owner control
10617        // edition (`citationOk`: "a non-owner action MUST cite its grant"), so
10618        // an uncited Vector admin's ban/role/channel edit was silently dropped
10619        // by every Armada client — only the owner's actions crossed. The
10620        // citation must name the actor's OWN grant coordinate, at the version
10621        // and edition hash the verifier can match against a grant it holds.
10622        let (bed, owner, admin) = TestBed::new();
10623        bed.swap_to(&owner);
10624        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
10625        let rid = "c1".repeat(32);
10626        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
10627        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
10628
10629        // The owner's own edition carries NO citation: their rank is the id.
10630        let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
10631        edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
10632        let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
10633            .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
10634            .max_by_key(|e| e.version).expect("the owner's metadata edition");
10635        assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
10636
10637        // The admin JOINS and folds — the citation names the grant head their own
10638        // client has actually synced, so the fold must have persisted it.
10639        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10640        let bundle_json = serde_json::to_string(&bundle).unwrap();
10641        bed.swap_to(&admin);
10642        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10643        let _ = follow_control(&bed.relay, &joined).await;
10644        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10645        set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
10646
10647        let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
10648            .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
10649            .expect("the admin's banlist edition");
10650        let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
10651        assert_eq!(
10652            cite.entity_id,
10653            crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
10654            "the citation must name the ACTOR'S OWN grant coordinate",
10655        );
10656        assert!(cite.version >= 1, "pinned to a real grant version");
10657    }
10658
10659    #[tokio::test]
10660    async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
10661        // `cap_relays` is the truncate-on-read invariant everywhere else, and the
10662        // fold is a boundary like any other: MANAGE_METADATA makes an editor
10663        // authorized, not trusted. An oversize list costs every member a fan-out
10664        // per publish and the slowest of N per fetch — and Armada caps at 5, so
10665        // an uncapped fold also splits the two clients' operative sets.
10666        let (_tmp, _guard, _owner) = init_test_db();
10667        let relay = MemoryRelay::new();
10668        let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
10669
10670        let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
10671        let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
10672        edit_community_metadata(&relay, &community, &meta).await.unwrap();
10673
10674        let updated = follow_control(&relay, &community).await.unwrap()
10675            .expect("the metadata edition is folded");
10676        assert_eq!(
10677            updated.relays.len(),
10678            crate::community::MAX_COMMUNITY_RELAYS,
10679            "a folded relay list must be truncated, never adopted whole",
10680        );
10681
10682        // …and the fold must SETTLE: comparing an oversize edition against the
10683        // capped working set would never be equal, so every later fold would
10684        // report a change and re-save forever.
10685        let again = follow_control(&relay, &updated).await.unwrap();
10686        assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
10687    }
10688
10689    #[tokio::test]
10690    async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
10691        // One Invite List spans every community, so "I hold links" must never be
10692        // read as "I hold links HERE". A member with links elsewhere adopting a
10693        // rotation would otherwise publish an empty Registry edition into this
10694        // community — a control-plane write and a version bump on a coordinate
10695        // they never owned, every rotation, forever.
10696        let (bed, owner, me) = TestBed::new();
10697        bed.swap_to(&owner);
10698        let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
10699        let rid = "b2".repeat(32);
10700        publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10701        publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
10702
10703        let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10704        let bundle_json = serde_json::to_string(&bundle).unwrap();
10705        bed.swap_to(&me);
10706        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10707        let _ = follow_control(&bed.relay, &joined).await;
10708        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10709
10710        // My only link lives in a DIFFERENT community — one I own, since minting
10711        // is offer-gated on CREATE_INVITE.
10712        let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
10713        mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
10714
10715        let before = bed.relay.stored_count();
10716        let new_root = [0xE1; 32];
10717        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
10718        let rotation_events = bed.relay.stored_count() - before;
10719
10720        let after_adopt = bed.relay.stored_count();
10721        follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10722        assert_eq!(
10723            bed.relay.stored_count(),
10724            after_adopt,
10725            "adopting a rotation must publish NOTHING when I minted no links here",
10726        );
10727        assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
10728    }
10729
10730    #[tokio::test]
10731    async fn an_expired_link_stops_keeping_the_community_public() {
10732        // CORD-05 §1/§5: expiry is the one way a link dies with no user action.
10733        // A joiner is refused by `InviteBundle::expired`, so leaving the link in
10734        // the Registry states a door that isn't there — the aggregate never
10735        // empties and the community reads Public forever, silently inverting
10736        // every gate that hangs off that reading.
10737        let (_tmp, _guard, _owner) = init_test_db();
10738        let relay = MemoryRelay::new();
10739        let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
10740
10741        // A link that lapsed a minute ago.
10742        let past = now_ms() - 60_000;
10743        mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
10744        assert!(
10745            !community_is_public(&relay, &community).await,
10746            "an already-expired link must never read as a live door",
10747        );
10748
10749        // …and one that hasn't, to prove the filter isn't just dropping everything.
10750        mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
10751        assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
10752    }
10753
10754    #[tokio::test]
10755    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
10756        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
10757        // link publishes it (Public); retiring the last link empties it (Private).
10758        let (_tmp, _guard, _owner) = init_test_db();
10759        let relay = MemoryRelay::new();
10760        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
10761        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
10762
10763        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
10764        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
10765        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
10766        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
10767
10768        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
10769        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
10770        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
10771        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
10772        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
10773    }
10774
10775    #[tokio::test]
10776    async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
10777        // Every caller reads the `invite_registry` COLUMN, never the async fold. v2
10778        // published the Registry to the plane but never mirrored it locally, so every
10779        // v2 community read Private no matter how many live links it had.
10780        let (_tmp, _guard, _owner) = init_test_db();
10781        let relay = MemoryRelay::new();
10782        let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
10783        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10784        let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
10785        // The per-creator split is a SEPARATE table, and it drives the "first link flips
10786        // the community Public" confirm — an empty one re-asks on every later link.
10787        let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
10788        assert!(cached().is_empty(), "a fresh community caches an empty registry");
10789        assert!(per_creator().is_empty(), "…and no per-creator sets");
10790
10791        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
10792        assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
10793        let sets = per_creator();
10794        assert_eq!(sets.len(), 1, "the minting creator gets a set");
10795        assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
10796
10797        // Both caches must SHRINK too — a union-only mirror would strand it Public.
10798        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
10799        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
10800        assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
10801        assert!(per_creator().is_empty(), "…and clears the per-creator sets");
10802    }
10803
10804    #[tokio::test]
10805    async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
10806        // Registries are coordinate-bound to their creator, but `fold_head` picks an
10807        // equal-version winner AUTHOR-BLIND, by lowest inner id — and an author grinds
10808        // that freely by varying content. Folding before authorising would let any
10809        // member occupy the owner's registry head, fail the authority check, and drop
10810        // the whole registry: a live invite link silently retired, flipping the
10811        // community to Private and steering a moderator into the wrong ban remedy.
10812        let (_tmp, _guard, owner) = init_test_db();
10813        let relay = MemoryRelay::new();
10814        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
10815        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
10816        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
10817
10818        let cid = community.id();
10819        let control = control::ControlPlane::of(&community).write_group().unwrap();
10820        let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
10821
10822        let query = Query {
10823            kinds: vec![stream::KIND_WRAP],
10824            authors: vec![control.pk_hex()],
10825            limit: Some(FOLLOW_PAGE),
10826            ..Default::default()
10827        };
10828        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
10829        let target = wraps
10830            .iter()
10831            .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
10832            .filter(|e| e.entity_id == eid)
10833            .max_by_key(|e| e.version)
10834            .expect("the owner published a registry");
10835
10836        // Grind a same-version fork under the owner's coordinate that OUTRANKS the
10837        // real head on the tiebreak (~2 tries against a uniform id).
10838        let rogue = Keys::generate();
10839        let mut planted = false;
10840        for n in 0..4_000u64 {
10841            let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
10842            let rumor = control::build_edition_rumor(
10843                rogue.public_key(),
10844                vsk::INVITE_LINKS,
10845                &eid,
10846                target.version,
10847                target.prev_hash.as_ref(),
10848                &content,
10849                9_000,
10850                None,
10851            );
10852            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
10853            let (ed, _) = control::open_control_edition(&w, &control).unwrap();
10854            if ed.inner_id < target.inner_id {
10855                relay.publish(&w, &community.relays).await.unwrap();
10856                planted = true;
10857                break;
10858            }
10859        }
10860        assert!(planted, "the test needs a fork that wins the tiebreak");
10861
10862        assert!(
10863            community_is_public(&relay, &community).await,
10864            "an unauthorised fork must not retire the owner's live link"
10865        );
10866    }
10867
10868    #[tokio::test]
10869    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
10870        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
10871        let (_tmp, _guard, owner) = init_test_db();
10872        let relay = MemoryRelay::new();
10873        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
10874        let rogue = Keys::generate();
10875        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
10876        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
10877        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
10878        let group = control::ControlPlane::of(&community).write_group().unwrap();
10879        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
10880        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
10881        relay.publish(&wrap, &community.relays).await.unwrap();
10882        let _ = owner;
10883        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
10884    }
10885
10886    #[tokio::test]
10887    async fn full_lifecycle_e2e() {
10888        // The whole stack end to end across two accounts: create -> Public link ->
10889        // owner grants an admin -> member joins + reads history -> admin edits metadata
10890        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
10891        // Refounding) -> the banned member is severed AND stays banned across the new
10892        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
10893        let (bed, owner, member) = TestBed::new();
10894
10895        bed.swap_to(&owner);
10896        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
10897        let general = community.channels[0].id;
10898        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
10899
10900        // Public link → the community reads Public.
10901        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
10902        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
10903
10904        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
10905        let rid = "aa".repeat(32);
10906        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10907        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
10908
10909        // Member joins from the bundle + reads the owner's message.
10910        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10911        let bundle_json = serde_json::to_string(&bundle).unwrap();
10912        bed.swap_to(&member);
10913        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10914        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
10915        // The admin folds first — adopting the staff write key their Grant
10916        // delivered (CORD-04 §3) — then renames the community.
10917        let _ = follow_control(&bed.relay, &joined).await;
10918        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10919        assert!(joined.control_root.is_some(), "the Grant's control_wrap was adopted on fold");
10920        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
10921
10922        // Owner follows: the admin's rename folds (authorized).
10923        bed.swap_to(&owner);
10924        let updated = follow_control(&bed.relay, &community).await.unwrap().expect("the admin edit folds");
10925        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
10926
10927        // Ban the member (the three-removal composition, in order).
10928        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
10929        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
10930        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
10931        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
10932        // The ban survives the Refounding (the banlist head compacted forward).
10933        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
10934        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
10935        // Pre-ban history still reads across the new epoch.
10936        assert!(
10937            all_texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
10938            "pre-refounding history stays reachable by paging"
10939        );
10940
10941        // The banned member's rekey-follow concludes they're severed. Guard captured AFTER
10942        // the swap (the harness swap bumps the generation like production).
10943        bed.swap_to(&member);
10944        let session = crate::db::current_session();
10945        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10946        assert!(follow.self_removed, "the banned member is cryptographically cut");
10947
10948        // Owner dissolves → sealed.
10949        bed.swap_to(&owner);
10950        dissolve_community(&bed.relay, &refounded).await.unwrap();
10951        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
10952    }
10953
10954    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
10955    /// over one shared relay, create → channels (public + private) → converse both ways →
10956    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
10957    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
10958    /// doubles as a reference transcript and a re-runnable regression.
10959    #[tokio::test]
10960    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
10961        // A member forges a higher-version role edition at the admin coordinate before a
10962        // refounding. The compaction must carry the AUTHORIZED floor head, not the
10963        // author-blind version tip — else the forgery is re-anchored, honest folders drop
10964        // it, and the admin role vanishes at the new epoch (silent suppression).
10965        let (bed, owner, member) = TestBed::new();
10966        let attacker = Keys::generate();
10967        bed.swap_to(&owner);
10968        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
10969        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
10970        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10971        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
10972        // Owner folds → the authorized role/grant heads are floored.
10973        follow_control(&bed.relay, &community).await.unwrap();
10974        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
10975
10976        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
10977        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;
10978
10979        // Owner refounds (keeping everyone).
10980        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
10981        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
10982
10983        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
10984        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
10985        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
10986    }
10987
10988    #[tokio::test]
10989    async fn memberlist_survives_a_refounding_via_the_snapshot() {
10990        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
10991        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
10992        let (bed, owner, member) = TestBed::new();
10993        bed.swap_to(&owner);
10994        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
10995
10996        // Member joins (a Guestbook Join at epoch 0).
10997        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
10998        bed.swap_to(&member);
10999        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11000        bed.swap_to(&owner);
11001        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
11002
11003        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
11004        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
11005        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
11006
11007        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
11008        let members = memberlist(&bed.relay, &refounded).await.unwrap();
11009        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
11010        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
11011    }
11012
11013    #[tokio::test]
11014    async fn e2e_two_accounts_channels_converse_moderate() {
11015        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
11016        use nostr_sdk::prelude::ToBech32;
11017        let (bed, a, b) = TestBed::new();
11018        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
11019        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
11020        println!("\n===== Concord v2 deep e2e =====");
11021        println!("[acct] A (owner)  = {a_npub}");
11022        println!("[acct] B (member) = {b_npub}");
11023
11024        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
11025        bed.swap_to(&a);
11026        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
11027        let general = community.channels[0].id;
11028        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
11029
11030        // A PRIVATE channel via the REAL create path: an independent key minted at
11031        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
11032        // then announced (vsk 2) — later carried to B in the join bundle.
11033        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
11034        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11035        let priv_ch = community.channel(&priv_id).unwrap();
11036        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
11037        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
11038
11039        // Two more PUBLIC channels via the real create path.
11040        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
11041        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11042        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
11043        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11044        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
11045        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
11046
11047        // A talks in a few channels.
11048        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
11049        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
11050        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
11051        println!("[msg] A posted in #general / #announcements / #mods");
11052
11053        // ── A grants B admin, mints a public link, B joins from the bundle ──
11054        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
11055        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
11056        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
11057        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
11058        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
11059        println!("[invite] granted B @admin · minted link {}", link.url);
11060
11061        // A private channel is readable only by granted role-holders (CORD-03), so
11062        // B is added to its access list before the bundle is minted.
11063        grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
11064        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
11065        bed.swap_to(&b);
11066        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11067        println!("[join] B joined; sees {} channels", b_view.channels.len());
11068        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
11069        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");
11070        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
11071        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");
11072        // B folds the control plane (persisting the roster) — the live worker does
11073        // this right after any join; B's admin standing gates B's channel ops below.
11074        if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap() {
11075            b_view = fresh;
11076        }
11077        println!("[follow] B folded control (roster persisted: B is @admin)");
11078
11079        // ── Conversation both ways + persistence (get_messages-level) ──
11080        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
11081        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
11082        println!("[msg] B replied in #general + #mods");
11083        // Persist B's own #general view into the shared store (what sync/live ingest does)
11084        // and confirm it reads back via STATE — get_messages parity.
11085        let my_pk = b.keys.public_key();
11086        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
11087        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
11088            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
11089            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
11090        }
11091        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
11092        println!("[persist] #general history persisted into the shared events store");
11093
11094        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
11095        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
11096        bed.swap_to(&a);
11097        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
11098        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
11099        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
11100        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
11101        println!("[ops] reaction + edit + delete round-tripped");
11102
11103        // ── B creates a channel as admin, A folds it in ──
11104        bed.swap_to(&b);
11105        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
11106        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
11107        bed.swap_to(&a);
11108        if let Some(updated) = follow_control(&bed.relay, &community).await.unwrap() {
11109            community = updated;
11110        }
11111        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
11112        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
11113
11114        // ── A creates a SECOND private channel while B is already a member. B is
11115        // NOT on its access list, so B learns the channel exists (control-follow,
11116        // keyless) and gets no key: CORD-03's private channel is readable only by
11117        // granted role-holders, never by every member. B keys up if and when A
11118        // grants them the channel's access role and vends the key ──
11119        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
11120        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11121        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
11122        println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
11123        bed.swap_to(&b);
11124        let session_b2 = crate::db::current_session();
11125        if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap() {
11126            b_view = fresh;
11127        }
11128        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
11129        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
11130        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
11131        if let Some(fresh) = rf.updated {
11132            b_view = fresh;
11133        }
11134        let ch = b_view.channel(&vault).expect("still recorded");
11135        assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
11136        assert!(
11137            texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
11138            "and reads nothing from it"
11139        );
11140        assert!(
11141            send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
11142            "an unentitled member cannot post into the channel either"
11143        );
11144        bed.swap_to(&a);
11145        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11146        println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
11147
11148        // ── Members ──
11149        let members = memberlist(&bed.relay, &community).await.unwrap();
11150        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
11151        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
11152        println!("[members] {} members: A + B present", members.len());
11153
11154        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
11155        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
11156        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
11157        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
11158        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
11159        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
11160        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
11161        assert!(all_texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history pages back across the new epoch");
11162        assert!(
11163            all_texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
11164            "PRIVATE history pages back across the channel's own rotation (per-channel multi-epoch archive)"
11165        );
11166        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
11167        // B concludes it's severed.
11168        bed.swap_to(&b);
11169        let session_b3 = crate::db::current_session();
11170        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
11171        println!("[ban] B's rekey-follow: self_removed = true (severed)");
11172
11173        // ── Unban: A lifts the ban ──
11174        bed.swap_to(&a);
11175        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
11176        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
11177        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
11178        println!("[unban] B removed from the banlist (re-invitable)");
11179
11180        // ── Dissolve ──
11181        dissolve_community(&bed.relay, &refounded).await.unwrap();
11182        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
11183        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
11184    }
11185
11186    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
11187    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
11188    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
11189    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
11190    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
11191    #[tokio::test]
11192    #[ignore]
11193    async fn live_e2e_two_accounts() {
11194        use crate::community::transport::LiveTransport;
11195        use nostr_sdk::prelude::ToBech32;
11196
11197        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
11198        let relays = vec![relay.clone()];
11199        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
11200        crate::db::close_database();
11201        crate::db::clear_id_caches();
11202        let tmp = tempfile::tempdir().unwrap();
11203        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
11204
11205        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
11206        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
11207        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
11208
11209        let log = |line: String| {
11210            println!("{line}");
11211            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
11212                use std::io::Write;
11213                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
11214                    let _ = writeln!(f, "{line}");
11215                }
11216            }
11217        };
11218        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
11219        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
11220        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
11221
11222        for k in [&a, &b] {
11223            let npub = k.public_key().to_bech32().unwrap();
11224            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
11225            crate::db::set_current_account(npub.clone()).unwrap();
11226            crate::db::init_database(&npub).unwrap();
11227        }
11228        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
11229        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
11230        let client = crate::nostr_client_builder().build();
11231        client.add_managed_relay(relay.as_str()).await.ok();
11232        client.connect().await;
11233        crate::state::set_nostr_client(client);
11234        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
11235        let become_acct = |k: &Keys| {
11236            let npub = k.public_key().to_bech32().unwrap();
11237            crate::db::set_current_account(npub.clone()).unwrap();
11238            crate::db::init_database(&npub).unwrap();
11239            crate::db::clear_id_caches();
11240            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
11241            crate::state::set_my_public_key(k.public_key());
11242        };
11243        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
11244
11245        // A: create + a channel + grant B admin + mint link.
11246        become_acct(&a);
11247        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
11248        let general = community.channels[0].id;
11249        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
11250        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
11251        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
11252        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11253        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
11254        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
11255        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
11256        log(format!("[invite] B granted @admin · link {}", link.url));
11257        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
11258        settle().await;
11259
11260        // B: join + read A's history + reply.
11261        become_acct(&b);
11262        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
11263        log(format!("[join] B joined; {} channels", b_view.channels.len()));
11264        settle().await;
11265        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
11266        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
11267        log(format!("[read] B sees #general: {seen:?}"));
11268        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
11269        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
11270
11271        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
11272        // sends) directly onto the chat plane — proving the cross-client thread
11273        // RECEIVE path works live, not just in the offline fixture.
11274        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
11275        let hello_id = hello.event.opened().rumor_id.to_hex();
11276        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
11277        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
11278        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());
11279        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
11280        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
11281        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
11282        settle().await;
11283
11284        // A reads the thread reply back, rendered inline with A's message as parent.
11285        become_acct(&a);
11286        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11287        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
11288        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
11289        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
11290            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
11291            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");
11292        } else {
11293            panic!("the 1111 parsed as a Message");
11294        }
11295        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
11296        become_acct(&b);
11297        settle().await;
11298
11299        // A: create a PRIVATE channel while B is already a member — B is a recipient
11300        // of the creation delivery, so B keys up from the rekey plane over the real
11301        // relay (no bundle involved), then the two converse on it.
11302        become_acct(&a);
11303        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11304        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
11305        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11306        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
11307        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
11308        settle().await;
11309
11310        become_acct(&b);
11311        let session_b = crate::db::current_session();
11312        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
11313        if let Some(fresh) = follow_control(&transport, &b_view).await.expect("B control follow") {
11314            b_view = fresh;
11315        }
11316        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
11317            b_view = fresh;
11318        }
11319        let vch = b_view.channel(&vault).expect("B folded the vault");
11320        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
11321        let vseen = texts_in(&transport, &b_view, &vault).await;
11322        log(format!("[read] B sees #vault: {vseen:?}"));
11323        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
11324        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
11325        settle().await;
11326
11327        become_acct(&a);
11328        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11329        assert!(
11330            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
11331            "A reads B's private reply"
11332        );
11333        log("[private] two-way #vault conversation over the live relay".to_string());
11334
11335        // A: ban B (three-removal) + dissolve.
11336        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
11337        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
11338        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
11339        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
11340        settle().await;
11341        dissolve_community(&transport, &refounded).await.expect("dissolve");
11342        log("[dissolve] community sealed".to_string());
11343        log("===== LIVE e2e PASS =====".to_string());
11344    }
11345
11346    #[tokio::test]
11347    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
11348        // The tombstone rides its own public plane, watched live — an OFFLINE
11349        // member's catch-up must fetch it too, or they follow (and post into) a
11350        // grave forever.
11351        let (bed, owner, member) = TestBed::new();
11352        bed.swap_to(&owner);
11353        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
11354        let general = community.channels[0].id;
11355        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11356
11357        bed.swap_to(&member);
11358        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11359        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11360
11361        // The owner dissolves while the member sleeps.
11362        bed.swap_to(&owner);
11363        dissolve_community(&bed.relay, &community).await.unwrap();
11364
11365        // The member's catch-up learns of the death, seals, and refuses to post.
11366        bed.swap_to(&member);
11367        let session = crate::db::current_session();
11368        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11369        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
11370        assert!(!follow.self_removed && follow.updated.is_none());
11371        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
11372        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
11373        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
11374        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
11375        // Subsequent follows take the local fast path — still dissolved, no churn.
11376        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11377        assert!(again.dissolved && again.updated.is_none());
11378    }
11379
11380    #[tokio::test]
11381    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
11382        // Scale stress: MANY private channels, each rotated on every Refounding.
11383        // A member offline across two refoundings must converge on all of them
11384        // (the per-channel rotation fan in refound + the follow's channel×root×step
11385        // loops stay bounded) with every channel's history readable.
11386        const PRIV_CHANNELS: usize = 6;
11387        let (bed, owner, member) = TestBed::new();
11388        bed.swap_to(&owner);
11389        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
11390        let mut priv_ids = Vec::new();
11391        for i in 0..PRIV_CHANNELS {
11392            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
11393            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11394            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
11395            priv_ids.push(id);
11396        }
11397        // Private channels are readable only by granted role-holders (CORD-03).
11398        for id in &priv_ids {
11399            grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
11400        }
11401        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
11402
11403        // Member joins at epoch 0 with all channel keys, then goes offline.
11404        bed.swap_to(&member);
11405        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11406        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
11407
11408        // Two refoundings (each rotates the base + every private channel).
11409        bed.swap_to(&owner);
11410        for epoch in 1..=2u64 {
11411            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
11412            assert_eq!(community.root_epoch, Epoch(epoch));
11413            for id in &priv_ids {
11414                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
11415            }
11416        }
11417
11418        // Member returns: bounded follow to quiescence.
11419        bed.swap_to(&member);
11420        let session = crate::db::current_session();
11421        let mut passes = 0;
11422        loop {
11423            passes += 1;
11424            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
11425            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11426            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
11427            assert!(!rk.self_removed);
11428            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11429            let ctl = follow_control(&bed.relay, &cur).await.unwrap();
11430            if rk.updated.is_none() && ctl.is_none() {
11431                break;
11432            }
11433        }
11434        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11435        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
11436        // Every private channel converged to the owner's current key + reads all epochs.
11437        for id in &priv_ids {
11438            let mine = caught_up.channel(id).expect("channel survived");
11439            let theirs = community.channel(id).unwrap();
11440            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
11441            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
11442            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
11443            // Opening reads the live planes; the rotated-past ones are the
11444            // back-paging cursor's job. Both halves asserted — the point is that
11445            // every epoch stays REACHABLE, not that opening drags it all in.
11446            let open = texts_in(&bed.relay, &caught_up, id).await;
11447            assert!(open.iter().any(|t| t.contains("epoch2")), "channel {id_hex} opens onto its live epoch");
11448            let paged = all_texts_in(&bed.relay, &caught_up, id).await;
11449            assert!(paged.iter().any(|t| t.contains("epoch0")), "channel {id_hex} pages back to epoch-0 history");
11450            for epoch in 1..=2u64 {
11451                assert!(paged.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} pages back to epoch-{epoch} history");
11452            }
11453        }
11454    }
11455
11456    #[tokio::test]
11457    async fn an_offline_member_catches_up_across_three_refoundings() {
11458        // The deep offline-online scenario: a member sleeps through THREE
11459        // Refoundings, per-refound private-channel rotations, a mid-life private
11460        // channel CREATED while they slept, a public channel, a rename, and a
11461        // ban — then returns and converges by follow alone (no rejoin).
11462        use nostr_sdk::prelude::ToBech32;
11463        let (bed, owner, member) = TestBed::new();
11464        bed.swap_to(&owner);
11465        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
11466        let general = community.channels[0].id;
11467        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
11468        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11469        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
11470        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
11471        // Private channels are readable only by granted role-holders (CORD-03).
11472        grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
11473        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
11474
11475        // Member joins at epoch 0, then goes OFFLINE.
11476        bed.swap_to(&member);
11477        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11478        assert_eq!(member_view.root_epoch, Epoch(0));
11479
11480        // While they sleep, the owner reshapes everything across three epochs.
11481        bed.swap_to(&owner);
11482        let stranger = Keys::generate();
11483        for epoch in 1..=3u64 {
11484            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
11485            assert_eq!(community.root_epoch, Epoch(epoch));
11486            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
11487            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
11488        }
11489        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
11490        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11491        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
11492        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11493        // The sleeper is on this channel's access list, so the refoundings that
11494        // follow deliver its key to them (CORD-03).
11495        grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
11496        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11497        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
11498        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
11499        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
11500        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
11501
11502        // The member RETURNS: rekey+control follow to quiescence (the worker's
11503        // loop, driven explicitly). Bounded — convergence must be fast.
11504        bed.swap_to(&member);
11505        let session = crate::db::current_session();
11506        let mut passes = 0;
11507        loop {
11508            passes += 1;
11509            assert!(passes <= 6, "catch-up must converge, not churn");
11510            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11511            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
11512            assert!(!rekeyed.self_removed, "the member was never removed");
11513            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11514            let controlled = follow_control(&bed.relay, &cur).await.unwrap();
11515            if rekeyed.updated.is_none() && controlled.is_none() {
11516                break;
11517            }
11518        }
11519        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
11520
11521        // Base + name converged.
11522        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
11523        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
11524        assert_eq!(caught_up.name, "Sleeper Reborn");
11525        // Channels: renamed set incl. the mid-sleep public + private ones.
11526        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
11527        let m = caught_up.channel(&mods).expect("mods survived");
11528        let owner_mods = community.channel(&mods).unwrap();
11529        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
11530        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
11531        let v = caught_up.channel(&vault).expect("vault folded in");
11532        // The sleeper is on vault's access list, but it was created AFTER the last
11533        // refounding — no rotation followed the grant, so no blob was ever
11534        // addressed to them. They hold the channel keyless until the grant's own
11535        // key vend lands (CORD-05 §6), which is what a rekey-only walk cannot do.
11536        assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
11537        // Banlist survived the compactions.
11538        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
11539        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
11540        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
11541        // Opening reads the live planes only; paging back reaches EVERY epoch
11542        // (public via the base-root archive, private via the per-channel archive
11543        // built during the walk). A sleeper's deep history stays reachable — it
11544        // just arrives when they scroll for it rather than on open.
11545        let gen_open = texts_in(&bed.relay, &caught_up, &general).await;
11546        assert!(gen_open.contains(&"epoch3: general news".to_string()), "general opens onto its live epoch: {gen_open:?}");
11547        let gen_texts = all_texts_in(&bed.relay, &caught_up, &general).await;
11548        for epoch in 0..=3u64 {
11549            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
11550            assert!(gen_texts.contains(&needle), "general pages back to epoch {epoch}: {gen_texts:?}");
11551        }
11552        let mods_open = texts_in(&bed.relay, &caught_up, &mods).await;
11553        assert!(mods_open.contains(&"epoch3: mods word".to_string()), "mods opens onto its live epoch: {mods_open:?}");
11554        let mods_texts = all_texts_in(&bed.relay, &caught_up, &mods).await;
11555        for epoch in 0..=3u64 {
11556            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
11557            assert!(mods_texts.contains(&needle), "private pages back to epoch {epoch}: {mods_texts:?}");
11558        }
11559        // Keyless (above) means unreadable — a rekey walk cannot substitute for the
11560        // key vend that a grant carries.
11561        assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
11562        // And the member can still speak.
11563        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
11564        bed.swap_to(&owner);
11565        assert!(
11566            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
11567            "the caught-up member converses at the new epoch ({})",
11568            member.keys.public_key().to_bech32().unwrap()
11569        );
11570    }
11571
11572    /// Seal `n` messages onto a community's #general, one per second starting at
11573    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
11574    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
11575        let general = community.channels[0].id;
11576        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11577        for i in 0..n {
11578            let at = base_secs + i as u64;
11579            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
11580            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
11581            relay.publish(&wrap, &community.relays).await.unwrap();
11582        }
11583    }
11584
11585    #[tokio::test]
11586    async fn the_history_walk_pages_past_a_multi_page_burst() {
11587        // A bot offline through 120 messages must catch ALL of them, not the
11588        // newest page — the v1 sync-gap class, closed by until-paging.
11589        let (_tmp, _guard, owner) = init_test_db();
11590        let relay = MemoryRelay::new();
11591        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
11592        let general = community.channels[0].id;
11593        flood_general(&relay, &community, &owner, 120, 10_000).await;
11594
11595        let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
11596        assert_eq!(all.len(), 120, "the walk pages the whole burst");
11597        // Oldest→newest, no duplicates.
11598        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
11599        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
11600        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
11601        let unique: std::collections::HashSet<&String> = contents.iter().collect();
11602        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
11603
11604        // The single-page fetch stays a single page.
11605        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
11606        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
11607        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
11608    }
11609
11610    #[tokio::test]
11611    async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
11612        // The back-paging cursor: a walk that starts at an explicit `until`
11613        // returns only what lies at-or-before it, oldest→newest — the relay-side
11614        // half of the SDK's walk-until-dry loop.
11615        let (_tmp, _guard, owner) = init_test_db();
11616        let relay = MemoryRelay::new();
11617        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
11618        let general = community.channels[0].id;
11619        flood_general(&relay, &community, &owner, 120, 10_000).await;
11620
11621        let older = fetch_channel_history(
11622            &relay, &community, &general, 50, 8, None, Some(10_059),
11623            crate::community::transport::Evidence::Quorum, |_| true,
11624        )
11625        .await
11626        .unwrap();
11627        let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
11628        assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
11629        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
11630        assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
11631    }
11632
11633    #[tokio::test]
11634    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
11635        let (_tmp, _guard, owner) = init_test_db();
11636        let relay = MemoryRelay::new();
11637        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
11638        let general = community.channels[0].id;
11639        flood_general(&relay, &community, &owner, 120, 10_000).await;
11640
11641        // The caller says "I hold everything" after the first page — no deeper fetch.
11642        let mut pages = 0usize;
11643        let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
11644            pages += 1;
11645            false
11646        })
11647        .await
11648        .unwrap();
11649        assert_eq!(pages, 1, "the early stop is consulted once");
11650        assert_eq!(got.len(), 50, "only the newest page is fetched");
11651        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
11652    }
11653
11654    #[tokio::test]
11655    async fn a_same_second_history_wall_terminates_instead_of_looping() {
11656        // 60 messages in ONE second with a 25-wrap page: a second-granular
11657        // `until` can never page past the wall — the walk must step over it
11658        // (bounded loss, logged) rather than spin.
11659        let (_tmp, _guard, owner) = init_test_db();
11660        let relay = MemoryRelay::new();
11661        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
11662        let general = community.channels[0].id;
11663        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11664        for i in 0..60usize {
11665            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
11666            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
11667            relay.publish(&wrap, &community.relays).await.unwrap();
11668        }
11669        let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
11670        assert!(got.len() >= 25, "at least the relay page is read");
11671        assert!(got.len() <= 60, "sane bound");
11672        // Termination is the assertion: reaching here means the wall didn't loop.
11673    }
11674
11675    #[tokio::test]
11676    async fn a_grant_revoke_survives_a_withholding_relay() {
11677        // Floor persistence on the delegation plane: after the owner revokes an admin,
11678        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
11679        let (_tmp, _guard, owner) = init_test_db();
11680        let relay = MemoryRelay::new();
11681        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
11682        let admin = Keys::generate();
11683        let rid = "d4".repeat(32);
11684        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
11685        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
11686        follow_control(&relay, &community).await.unwrap(); // seed floors incl. the grant at v1
11687        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
11688        follow_control(&relay, &community).await.unwrap();
11689
11690        // A stale relay serves only the grant prefix (v1, the live grant).
11691        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
11692        let mut stale = community.clone();
11693        stale.relays = vec!["wss://stale".into()];
11694        let floors = load_floors(&community);
11695        let editions = fetch_control(&relay, &stale).await;
11696        let authority = fold_authority(&stale, &editions, &floors);
11697        assert!(
11698            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
11699            "the persisted grant floor refuses the rolled-back (re-granted) view"
11700        );
11701    }
11702
11703    /// Load the current-epoch floors for a community (test mirror of follow_control).
11704    fn load_floors(community: &CommunityV2) -> Floors {
11705        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11706        crate::db::community::get_all_edition_heads_full(&cid_hex)
11707            .unwrap_or_default()
11708            .into_iter()
11709            .filter(|(_, f)| f.0 == community.root_epoch.0)
11710            .map(|(e, f)| (e, (f.1, f.2, f.3)))
11711            .collect()
11712    }
11713
11714    /// Fetch + open every control edition at a community's control plane (test helper).
11715    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
11716        let group = control::ControlPlane::of(&community).write_group().unwrap();
11717        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11718        relay
11719            .fetch(&q, &community.relays)
11720            .await
11721            .unwrap_or_default()
11722            .iter()
11723            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
11724            .collect()
11725    }
11726
11727    #[tokio::test]
11728    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
11729        let (_tmp, _guard, _owner) = init_test_db();
11730        let relay = MemoryRelay::new();
11731        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
11732        // Only the genesis editions exist; folding them reproduces the held view.
11733        assert!(follow_control(&relay, &community).await.unwrap().is_none());
11734    }
11735
11736    #[tokio::test]
11737    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
11738        let (_tmp, _guard, owner) = init_test_db();
11739        let relay = MemoryRelay::new();
11740        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
11741        let new_id = ChannelId([0x5a; 32]);
11742        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
11743
11744        let updated = follow_control(&relay, &community).await.unwrap().expect("a new channel changed the view");
11745        assert_eq!(updated.channels.len(), 2);
11746        let added = updated.channel(&new_id).expect("the new channel folded in");
11747        assert_eq!(added.name, "announcements");
11748        assert!(!added.private);
11749        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
11750
11751        // The new channel is now in the realtime author-set (it would be subscribed).
11752        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
11753        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
11754        assert!(authors.contains(&addr), "the added channel joins the live subscription");
11755
11756        // Persisted: a reload sees it too.
11757        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11758        assert!(reloaded.channel(&new_id).is_some());
11759    }
11760
11761    #[tokio::test]
11762    async fn follow_control_renames_the_community_and_an_existing_channel() {
11763        let (_tmp, _guard, owner) = init_test_db();
11764        let relay = MemoryRelay::new();
11765        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
11766        let general = community.channels[0].id;
11767        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
11768        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
11769        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
11770
11771        let updated = follow_control(&relay, &community).await.unwrap().unwrap();
11772        assert_eq!(updated.name, "New Name");
11773        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
11774        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
11775    }
11776
11777    #[tokio::test]
11778    async fn follow_control_deletes_a_channel() {
11779        let (_tmp, _guard, owner) = init_test_db();
11780        let relay = MemoryRelay::new();
11781        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11782        let extra = ChannelId([0x77; 32]);
11783
11784        // The channel is first added and folded into the held view.
11785        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11786        let with_extra = follow_control(&relay, &community).await.unwrap().expect("added");
11787        assert!(with_extra.channel(&extra).is_some());
11788
11789        // Then it's tombstoned — the delete (higher version) folds the held one back out.
11790        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11791        let updated = follow_control(&relay, &with_extra).await.unwrap().expect("removed");
11792        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
11793        assert_eq!(updated.channels.len(), 1, "only #general remains");
11794    }
11795
11796    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
11797    /// community's control plane onto a second relay URL — the withholding-relay
11798    /// simulation: everything it serves is genuinely owner-signed, just stale.
11799    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
11800        let group = control::ControlPlane::of(&community).write_group().unwrap();
11801        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11802        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
11803        for w in &wraps {
11804            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
11805                if ed.version <= max_version {
11806                    relay.inject(w, &[stale_relay.to_string()]);
11807                }
11808            }
11809        }
11810    }
11811
11812    #[tokio::test]
11813    async fn a_withholding_relay_cannot_roll_back_a_rename() {
11814        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
11815        // only the (owner-signed) v1 genesis must not revert the held name.
11816        let (_tmp, _guard, owner) = init_test_db();
11817        let relay = MemoryRelay::new();
11818        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
11819        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
11820
11821        let updated = follow_control(&relay, &community).await.unwrap().expect("rename adopted");
11822        assert_eq!(updated.name, "Renamed");
11823
11824        // The stale relay holds only the genesis prefix; point the follow at it.
11825        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
11826        let mut stale_view = updated.clone();
11827        stale_view.relays = vec!["wss://stale".into()];
11828        assert!(
11829            follow_control(&relay, &stale_view).await.unwrap().is_none(),
11830            "a stale-only relay must not change the held view"
11831        );
11832        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11833        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
11834    }
11835
11836    #[tokio::test]
11837    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
11838        let (_tmp, _guard, owner) = init_test_db();
11839        let relay = MemoryRelay::new();
11840        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
11841        let extra = ChannelId([0x44; 32]);
11842
11843        // A same-content metadata edit: no visible change (None), but the floor must
11844        // still advance to v2 (so the genesis metadata can't re-present below).
11845        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
11846        assert!(follow_control(&relay, &community).await.unwrap().is_none());
11847
11848        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11849        let with_extra = follow_control(&relay, &community).await.unwrap().expect("added");
11850        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11851        let pruned = follow_control(&relay, &with_extra).await.unwrap().expect("removed");
11852        assert!(pruned.channel(&extra).is_none());
11853
11854        // The stale relay serves the add (v1) but withholds the delete (v2).
11855        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
11856        let mut stale_view = pruned.clone();
11857        stale_view.relays = vec!["wss://stale".into()];
11858        assert!(
11859            follow_control(&relay, &stale_view).await.unwrap().is_none(),
11860            "the withheld delete must not resurrect the channel"
11861        );
11862        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11863        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
11864    }
11865
11866    #[tokio::test]
11867    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
11868        // The Armada-convergence carve-out: a Refounding compacts the chain and
11869        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
11870        // floor must not block it — epoch-filtering makes the entity bootstrap.
11871        let (_tmp, _guard, owner) = init_test_db();
11872        let relay = MemoryRelay::new();
11873        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
11874        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
11875        let updated = follow_control(&relay, &community).await.unwrap().expect("edit adopted");
11876        assert_eq!(updated.name, "Edited");
11877
11878        // Refounding lands (epoch bump saved by the rekey path); the compacted head
11879        // arrives DETACHED (high version, no prev) on the new epoch's plane.
11880        let refounded = rotate_view(&updated, updated.community_root, 1);
11881        crate::db::community::save_community_v2(&refounded).unwrap();
11882        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
11883
11884        let adopted = follow_control(&relay, &refounded).await.unwrap().expect("compacted head adopted");
11885        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
11886        // The persisted floor is stamped with the epoch the FOLD ran under.
11887        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11888        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
11889        assert!(
11890            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
11891            "the adopted head carries the fold's epoch + version"
11892        );
11893    }
11894
11895    #[tokio::test]
11896    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
11897        // Two owner-signed editions at the SAME version (publish retry / two owner
11898        // devices): every client must land on the lower-inner-id winner. A hash-strict
11899        // floor would wedge here forever while Armada converges — the floor must
11900        // CONVERGE instead (the v1 decide() rule).
11901        let (_tmp, _guard, owner) = init_test_db();
11902        let relay = MemoryRelay::new();
11903        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
11904        let group = control::ControlPlane::of(&community).write_group().unwrap();
11905        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
11906
11907        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
11908        let ours = follow_control(&relay, &community).await.unwrap().expect("ours adopted");
11909        assert_eq!(ours.name, "Ours");
11910
11911        // Our committed v2 edition's tiebreak id.
11912        let our_inner = {
11913            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11914            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
11915            wraps
11916                .iter()
11917                .find_map(|w| {
11918                    control::open_control_edition(w, &group)
11919                        .ok()
11920                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
11921                        .map(|(ed, _)| ed.inner_id)
11922                })
11923                .unwrap()
11924        };
11925
11926        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
11927        // authored timestamp until its inner id is lower).
11928        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
11929        let content = serde_json::to_string(&meta).unwrap();
11930        let mut ts = 2_000u64;
11931        let fork_wrap = loop {
11932            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
11933            let inner = rumor.id.unwrap().to_bytes();
11934            if inner < our_inner {
11935                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
11936            }
11937            ts += 1;
11938        };
11939        relay.publish(&fork_wrap, &community.relays).await.unwrap();
11940
11941        let converged = follow_control(&relay, &ours).await.unwrap().expect("fork winner adopted");
11942        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
11943        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11944        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
11945        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
11946    }
11947
11948    #[tokio::test]
11949    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
11950        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
11951        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
11952        // while the detached v4 waits. When v3 lands, the chain heals to v4.
11953        let (_tmp, _guard, owner) = init_test_db();
11954        let relay = MemoryRelay::new();
11955        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
11956        let group = control::ControlPlane::of(&community).write_group().unwrap();
11957
11958        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
11959        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
11960
11961        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
11962        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
11963        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
11964        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
11965        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
11966        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
11967        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
11968        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
11969        relay.publish(&w4, &community.relays).await.unwrap();
11970
11971        let updated = follow_control(&relay, &community).await.unwrap().expect("the verified prefix applies");
11972        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
11973
11974        relay.publish(&w3, &community.relays).await.unwrap();
11975        let healed = follow_control(&relay, &updated).await.unwrap().expect("the chain heals");
11976        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
11977    }
11978
11979    #[tokio::test]
11980    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
11981        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
11982        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
11983        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
11984        let (_tmp, _guard, owner) = init_test_db();
11985        let relay = MemoryRelay::new();
11986        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
11987        let group = control::ControlPlane::of(&community).write_group().unwrap();
11988
11989        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
11990        let base = follow_control(&relay, &community).await.unwrap().expect("floor at v2");
11991        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
11992        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
11993
11994        // Rogue flood occupying the newest window (sealed to the control plane, but
11995        // non-owner — the authority gate drops them; they only crowd the page).
11996        let rogue = Keys::generate();
11997        for i in 0..(FOLLOW_PAGE as u64 - 1) {
11998            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
11999            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
12000            relay.publish(&w, &community.relays).await.unwrap();
12001        }
12002        // v4 chained to the real v3 (crafted directly: the flood also blinds the
12003        // helper's own newest-window head lookup), timestamped newest of all.
12004        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
12005        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
12006        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
12007        relay.publish(&w4, &community.relays).await.unwrap();
12008
12009        let healed = follow_control(&relay, &base).await.unwrap().expect("paging recovered the chain");
12010        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
12011    }
12012
12013    #[tokio::test]
12014    async fn a_follow_after_delete_does_not_resurrect_the_community() {
12015        // A leave/delete racing an in-flight follow: the follow must not re-insert
12016        // the community row or floor rows past delete_community's wipe.
12017        let (_tmp, _guard, owner) = init_test_db();
12018        let relay = MemoryRelay::new();
12019        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
12020        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
12021        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12022        crate::db::community::delete_community(&cid_hex).unwrap();
12023
12024        assert!(
12025            follow_control(&relay, &community).await.unwrap().is_none(),
12026            "a follow racing a delete is a no-op"
12027        );
12028        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
12029        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
12030    }
12031
12032    #[tokio::test]
12033    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
12034        // The rekey sibling of the follow_control guard: an owner rotation adopted
12035        // mid-race must not upsert the community row back after a leave/delete.
12036        let (_tmp, _guard, owner) = init_test_db();
12037        let relay = MemoryRelay::new();
12038        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
12039        let new_root = [0xB2; 32];
12040        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
12041        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12042        crate::db::community::delete_community(&cid_hex).unwrap();
12043
12044        let session = crate::db::current_session();
12045        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
12046        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
12047        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
12048    }
12049
12050    #[tokio::test]
12051    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
12052        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
12053        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
12054        // would take v1 and SEED the joiner's floor there — pinning them below the
12055        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
12056        let (bed, owner, member) = TestBed::new();
12057        bed.swap_to(&owner);
12058        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
12059        let group = control::ControlPlane::of(&community).write_group().unwrap();
12060        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
12061
12062        // v2 is crafted but NEVER published; v3 chains to it and is published.
12063        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
12064        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
12065        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
12066        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
12067        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
12068        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);
12069        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
12070        bed.relay.publish(&w3, &community.relays).await.unwrap();
12071
12072        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12073        let bundle_json = serde_json::to_string(&bundle).unwrap();
12074        bed.swap_to(&member);
12075        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12076        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
12077        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
12078        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
12079        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
12080    }
12081
12082    #[tokio::test]
12083    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
12084        // The refusal half of fork convergence: a relay withholding OUR committed
12085        // floor edition while serving only a same-version fork with a HIGHER inner
12086        // id must be treated as withholding — held state and floor unchanged.
12087        let (_tmp, _guard, owner) = init_test_db();
12088        let relay = MemoryRelay::new();
12089        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
12090        let group = control::ControlPlane::of(&community).write_group().unwrap();
12091        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
12092
12093        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
12094        let ours = follow_control(&relay, &community).await.unwrap().expect("ours adopted");
12095        assert_eq!(ours.name, "Ours");
12096        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12097        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
12098        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
12099
12100        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
12101        // with the genesis but WITHOUT our v2 — from a withholding relay.
12102        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
12103        let content = serde_json::to_string(&meta).unwrap();
12104        let mut ts = 5_000u64;
12105        let fork_wrap = loop {
12106            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
12107            if rumor.id.unwrap().to_bytes() > our_inner {
12108                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
12109            }
12110            ts += 1;
12111        };
12112        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
12113        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
12114        let mut stale_view = ours.clone();
12115        stale_view.relays = vec!["wss://stale".into()];
12116
12117        assert!(
12118            follow_control(&relay, &stale_view).await.unwrap().is_none(),
12119            "a losing fork served without our floor edition changes nothing"
12120        );
12121        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
12122        assert_eq!(held_after, held_before, "the floor row is untouched");
12123        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12124        assert_eq!(held.name, "Ours", "the held state is untouched");
12125    }
12126
12127    #[tokio::test]
12128    async fn follow_control_ignores_a_non_owner_edition() {
12129        // A member holds the community_root, so they CAN seal a control edition —
12130        // but they aren't the owner, so the authority gate drops it (first cut:
12131        // owner-only). The rogue channel must never appear.
12132        let (_tmp, _guard, _owner) = init_test_db();
12133        let relay = MemoryRelay::new();
12134        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
12135        let rogue = Keys::generate();
12136        let rogue_id = ChannelId([0x99; 32]);
12137        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
12138
12139        assert!(
12140            follow_control(&relay, &community).await.unwrap().is_none(),
12141            "a non-owner control edition is not folded"
12142        );
12143    }
12144
12145    #[tokio::test]
12146    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
12147        // A Private channel's key rides the rekey plane, not the control edition —
12148        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
12149        // every read/send path refuses it until the key lands (never the root plane).
12150        let (_tmp, _guard, owner) = init_test_db();
12151        let relay = MemoryRelay::new();
12152        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
12153        let priv_id = ChannelId([0x33; 32]);
12154        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
12155
12156        let updated = follow_control(&relay, &community)
12157            .await
12158            .unwrap()
12159            .expect("the keyless record is a change");
12160        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
12161        assert!(ch.private && ch.key.is_none(), "recorded keyless");
12162        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
12163        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
12164        assert!(
12165            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
12166            "a keyless fetch returns empty (and never queries the root plane)"
12167        );
12168        assert!(
12169            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
12170            "a keyless send refuses"
12171        );
12172        // The keyless record round-trips (the stored placeholder never surfaces
12173        // as a real key).
12174        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
12175        let rch = reloaded.channel(&priv_id).unwrap();
12176        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
12177        // And a bundle minted while keyless never carries the placeholder — a
12178        // MEMBER audience, so it's the keyless filter proving it (the link
12179        // filter would drop the channel for the weaker reason).
12180        let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
12181        assert!(
12182            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
12183            "an ungrantable keyless channel stays out of invite bundles"
12184        );
12185    }
12186
12187    #[tokio::test]
12188    async fn a_link_bundle_never_carries_a_private_channel_key() {
12189        // A link's audience holds no Role by construction (CORD-05), so a HELD
12190        // private key must never ride a link bundle — anyone with the URL would
12191        // get the channel. A member bundle carries it; a link bundle only the
12192        // public channels.
12193        let (_tmp, _guard, _owner) = init_test_db();
12194        let relay = MemoryRelay::new();
12195        let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
12196        create_private_channel(&relay, &community, "mods").await.unwrap();
12197        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12198        let priv_hex = held
12199            .channels
12200            .iter()
12201            .find(|c| c.private)
12202            .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
12203            .expect("the private channel is held WITH its key");
12204
12205        let link = bundle_of(&held, BundleAudience::Link, None, None, None);
12206        assert!(
12207            !link.channels.iter().any(|c| c.id == priv_hex),
12208            "a held private key must never ride a link bundle"
12209        );
12210        assert!(
12211            link.channels.iter().any(|c| c.id != priv_hex),
12212            "the public channels still ride it"
12213        );
12214
12215        // A member bundle grants it only to the ENTITLED. An unrelated npub holds
12216        // no scoped role, so it gets nothing; the creator (granted the companion
12217        // access role at create) gets the key.
12218        let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
12219        assert!(
12220            !stranger.channels.iter().any(|c| c.id == priv_hex),
12221            "an unentitled member gets no private key"
12222        );
12223        let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
12224        assert!(
12225            mine.channels.iter().any(|c| c.id == priv_hex),
12226            "the creator is entitled via the companion access role"
12227        );
12228    }
12229
12230    /// Mint a server-scope role at `position` carrying `permissions`, grant it to
12231    /// `member`, and return its id. Used to build a RANKED roster (the owner-only
12232    /// tests can't exercise outranking, since the owner outranks everyone).
12233    async fn seat_ranked_role(
12234        relay: &MemoryRelay,
12235        community: &CommunityV2,
12236        member: &PublicKey,
12237        name: &str,
12238        position: u32,
12239        permissions: crate::community::roles::Permissions,
12240    ) -> String {
12241        let role_id = crate::crypto::sha256_hex(format!("test/role/{name}/{position}").as_bytes());
12242        let role = crate::community::roles::Role {
12243            role_id: role_id.clone(),
12244            name: name.to_string(),
12245            position,
12246            permissions,
12247            scope: crate::community::roles::RoleScope::Server,
12248            color: 0,
12249        };
12250        set_role(relay, community, &role).await.unwrap();
12251        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12252        let mut ids: Vec<String> = {
12253            let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12254            let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12255            roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect()
12256        };
12257        if !ids.contains(&role_id) {
12258            ids.push(role_id.clone());
12259        }
12260        grant_roles(relay, &held, member, ids).await.unwrap();
12261        role_id
12262    }
12263
12264    /// Become `who` without touching the account DB — the acting identity only.
12265    /// Enough to exercise an offer-side authority gate from a non-owner's seat.
12266    fn act_as(who: &Keys) {
12267        crate::state::MY_SECRET_KEY.store_from_keys(who, &[]);
12268        crate::state::set_my_public_key(who.public_key());
12269    }
12270
12271    #[tokio::test]
12272    async fn a_moderator_cannot_revoke_channel_access_from_a_superior_and_the_channel_never_rotates() {
12273        // CORD-04 §2: the fold drops a Grant aimed at a peer or superior, but the
12274        // channel rotation is gated on the citation alone — so without an
12275        // offer-side rank check the Grant dies and the target is severed anyway.
12276        // The epoch assertion is the point: refusing the publish is worthless if
12277        // the rekey still runs.
12278        let (_tmp, _guard, owner) = init_test_db();
12279        let relay = MemoryRelay::new();
12280        let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
12281        let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
12282
12283        let superior = Keys::generate();
12284        let moderator = Keys::generate();
12285        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12286        grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
12287        seat_ranked_role(
12288            &relay,
12289            &community,
12290            &moderator.public_key(),
12291            "Moderator",
12292            5,
12293            crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
12294        )
12295        .await;
12296
12297        // The superior legitimately holds the channel (granted by the owner).
12298        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12299        grant_channel_access(&relay, &held, &vault, &superior.public_key()).await.unwrap();
12300        let before = crate::db::community::load_community_v2(community.id())
12301            .unwrap()
12302            .unwrap()
12303            .channel(&vault)
12304            .unwrap()
12305            .epoch;
12306
12307        act_as(&moderator);
12308        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12309        let err = revoke_channel_access(&relay, &held, &vault, &superior.public_key())
12310            .await
12311            .expect_err("a moderator must not act on an admin");
12312        assert!(err.contains("outrank"), "refused for RANK, not some incidental reason: {err}");
12313
12314        let after = crate::db::community::load_community_v2(community.id())
12315            .unwrap()
12316            .unwrap()
12317            .channel(&vault)
12318            .unwrap()
12319            .epoch;
12320        assert_eq!(after, before, "the refusal must also stop the rotation — the rekey IS the severance");
12321        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12322        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12323        let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
12324        let owner_hex = owner.public_key().to_hex();
12325        assert!(
12326            roster.is_entitled(Some(&owner_hex), &superior.public_key().to_hex(), &chan_hex, &[], &[]),
12327            "and the superior keeps their access role"
12328        );
12329    }
12330
12331    #[tokio::test]
12332    async fn a_moderator_cannot_grant_channel_access_to_a_superior_but_the_owner_stays_grantable() {
12333        // The grant direction of the same gate. The owner is never a valid rank
12334        // target, yet IS a legitimate recipient of a key an admin minted — the
12335        // rank check must not swallow that case.
12336        let (_tmp, _guard, owner) = init_test_db();
12337        let relay = MemoryRelay::new();
12338        let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
12339        let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
12340
12341        let superior = Keys::generate();
12342        let moderator = Keys::generate();
12343        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12344        grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
12345        let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
12346        // Scoped access so the moderator holds the channel key itself, else the
12347        // refusal could come from "we hold no key" and prove nothing about rank.
12348        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12349        grant_channel_access(&relay, &held, &vault, &moderator.public_key()).await.unwrap();
12350        seat_ranked_role(
12351            &relay,
12352            &community,
12353            &moderator.public_key(),
12354            "Moderator",
12355            5,
12356            crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
12357        )
12358        .await;
12359
12360        act_as(&moderator);
12361        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12362        let err = grant_channel_access(&relay, &held, &vault, &superior.public_key())
12363            .await
12364            .expect_err("a moderator must not grant against an admin");
12365        assert!(err.contains("outrank"), "refused for RANK: {err}");
12366
12367        // Same seat, same channel, owner as the target: allowed.
12368        grant_channel_access(&relay, &held, &vault, &owner.public_key())
12369            .await
12370            .expect("the owner is not a rank target, and may still be handed a key");
12371        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12372        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12373        let owner_hex = owner.public_key().to_hex();
12374        assert!(
12375            roster.is_entitled(Some(&owner_hex), &owner_hex, &chan_hex, &[], &[]),
12376            "the owner's access role landed"
12377        );
12378    }
12379
12380    #[tokio::test]
12381    async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
12382        // CORD-03/04: the roles scoped to a channel ARE its access list. Proven
12383        // against a NON-owner so the owner-is-always-entitled rule can't carry it.
12384        let (_tmp, _guard, _owner) = init_test_db();
12385        let relay = MemoryRelay::new();
12386        let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
12387        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12388        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12389        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12390
12391        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12392        let access = roster.channel_roles(&chan_hex);
12393        assert_eq!(access.len(), 1, "the channel minted exactly one access role");
12394        assert!(
12395            access[0].permissions == crate::community::roles::Permissions::empty(),
12396            "the access role confers READ access (key possession), never authority"
12397        );
12398        assert_eq!(access[0].name, "mods", "named for its channel");
12399
12400        // A stranger holds no scoped role: unentitled, and no key rides their bundle.
12401        let stranger = Keys::generate().public_key();
12402        let owner_hex = community.owner().unwrap().to_hex();
12403        assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
12404
12405        // Granting the access role entitles them; revoking un-entitles them. Both
12406        // proven through the roster, which is what routes keys.
12407        let role_id = access[0].role_id.clone();
12408        assert!(
12409            roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
12410            "the grant overlay entitles before the fold catches up"
12411        );
12412
12413        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12414        grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
12415        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
12416        assert!(
12417            after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
12418            "the grant landed in the local roster (the fold runs later)"
12419        );
12420        let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
12421        assert!(
12422            vend.channels.iter().any(|c| c.id == chan_hex),
12423            "a now-entitled member's bundle carries the channel key"
12424        );
12425
12426        revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
12427        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
12428        assert!(
12429            !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
12430            "the revoke dropped the access role"
12431        );
12432        let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12433        assert_eq!(
12434            rotated.channel(&priv_id).unwrap().epoch,
12435            Epoch(2),
12436            "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
12437        );
12438
12439        // The access summary a bot reads back: roles, holders, and key state.
12440        let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
12441        assert_eq!(access["private"], true);
12442        assert_eq!(access["readable"], true, "we minted it, so we hold its key");
12443        assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
12444        let holders = access["members"].as_array().unwrap();
12445        let me_npub = {
12446            use nostr_sdk::prelude::ToBech32;
12447            me_pk().unwrap().to_bech32().unwrap()
12448        };
12449        assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
12450        assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
12451    }
12452
12453    #[tokio::test]
12454    async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
12455        // CORD-06 §1 per channel: a Public-community ban skips the Refounding
12456        // (CORD-05 §5), so without this rotation the banned member keeps each
12457        // held channel key and reads on forever. Only channels they could
12458        // actually reach rotate — the rest keep their epoch.
12459        let (_tmp, _guard, _owner) = init_test_db();
12460        let relay = MemoryRelay::new();
12461        let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
12462        let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
12463        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12464        let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
12465        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12466
12467        let spammer = Keys::generate().public_key();
12468        let bystander = Keys::generate().public_key();
12469        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12470        grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
12471        grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
12472
12473        // The ban composition's capture-then-strip: entitlement must be judged
12474        // from the PRE-strip roles, whether or not the strip has folded.
12475        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12476        let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
12477        assert!(!stripped.is_empty(), "the grant landed before the strip");
12478        grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
12479
12480        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12481        let rotated = sever_banned_private_reads(&relay, &held, &[(spammer, stripped)]).await.unwrap();
12482        assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
12483        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12484        assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
12485        assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
12486
12487        // A member who never had reach rotates nothing.
12488        let stranger = Keys::generate().public_key();
12489        let n = sever_banned_private_reads(&relay, &after, &[(stranger, Vec::new())]).await.unwrap();
12490        assert_eq!(n, 0, "no entitlement, no rotation");
12491    }
12492
12493    #[tokio::test]
12494    async fn ban_severance_refuses_without_manage_channels() {
12495        // Offer-side mirror of `channel_rotator_ok`: readers honor a channel
12496        // rotation only from MANAGE_CHANNELS holders, so a BAN-only moderator
12497        // publishing one would adopt an epoch every reader rejects — a fork.
12498        let (bed, owner, moderator) = TestBed::new();
12499        bed.swap_to(&owner);
12500        let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
12501        create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12502        let rid = "c1".repeat(32);
12503        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12504        publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
12505        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12506        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12507        let bundle_json = serde_json::to_string(&bundle).unwrap();
12508
12509        bed.swap_to(&moderator);
12510        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12511        let _ = follow_control(&bed.relay, &joined).await;
12512        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12513        let target = Keys::generate().public_key();
12514        let err = sever_banned_private_reads(&bed.relay, &joined, &[(target, Vec::new())]).await.unwrap_err();
12515        assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
12516    }
12517
12518    #[tokio::test]
12519    async fn set_banlist_refuses_an_author_the_fold_would_reject() {
12520        // The reader gates the banlist head on BAN and each added entry on strict
12521        // outrank. Publishing anyway would be silently void everywhere while the
12522        // author's own echo caches the phantom — fail-closed at the offer instead.
12523        let (bed, owner, member) = TestBed::new();
12524        bed.swap_to(&owner);
12525        let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
12526        let rid = "c2".repeat(32);
12527        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12528        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12529        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12530        let bundle_json = serde_json::to_string(&bundle).unwrap();
12531
12532        // A roleless member holds no BAN: refused before any publish.
12533        bed.swap_to(&member);
12534        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12535        let _ = follow_control(&bed.relay, &joined).await;
12536        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12537        let target = Keys::generate().public_key().to_hex();
12538        let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
12539        assert!(err.contains("BAN"), "refused for the missing bit: {err}");
12540        assert!(
12541            crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
12542            "no phantom echo cached"
12543        );
12544
12545        // Grant them BAN: adding a peer they don't outrank still refuses (the
12546        // fold drops per-target on outrank), while an unranked target passes.
12547        bed.swap_to(&owner);
12548        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
12549        let peer = Keys::generate().public_key();
12550        publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
12551        bed.swap_to(&member);
12552        let _ = follow_control(&bed.relay, &joined).await;
12553        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12554        let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
12555        assert!(err.contains("outrank"), "an equal is not actionable: {err}");
12556        set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
12557    }
12558
12559    #[tokio::test]
12560    async fn metadata_and_link_minting_refuse_unauthorized_authors() {
12561        // Both are reader-gated (MANAGE_METADATA; CREATE_INVITE on the registry) —
12562        // the offer must mirror or the SDK reports success on a void publish.
12563        let (bed, owner, member) = TestBed::new();
12564        bed.swap_to(&owner);
12565        let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
12566        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12567        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12568        let bundle_json = serde_json::to_string(&bundle).unwrap();
12569
12570        bed.swap_to(&member);
12571        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12572        let _ = follow_control(&bed.relay, &joined).await;
12573        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
12574        let meta = control::CommunityMetadata {
12575            name: "Hijacked".into(),
12576            description: None,
12577            relays: vec![],
12578            icon: None,
12579            banner: None,
12580            custom: None,
12581            extra: Default::default(),
12582        };
12583        assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
12584        assert!(
12585            mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
12586            "link minting refused"
12587        );
12588    }
12589
12590    #[tokio::test]
12591    async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
12592        // JSKitty's race: the vend can land BEFORE the control fold that proves
12593        // the grant. It must park quietly (a lagging fold is not an anomaly) and
12594        // be adopted on the re-judge once the roster catches up.
12595        let (bed, owner, member) = TestBed::new();
12596        bed.swap_to(&owner);
12597        let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
12598        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12599        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12600        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12601        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12602        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
12603        let owner_hex = community.owner().unwrap().to_hex();
12604
12605        // Judge as the MEMBER — the owner is always entitled, so only a non-owner
12606        // can exercise the grant rule at all.
12607        bed.swap_to(&member);
12608        let me = member.keys.public_key().to_hex();
12609        // Their fold has the channel (control-follow records it keyless) but not
12610        // yet the grant that entitles them.
12611        let mut member_view = held.clone();
12612        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12613            c.key = None;
12614            c.epoch = Epoch(0);
12615        }
12616
12617        // Ungranted → PARK, never refuse: this is exactly the "not synced enough
12618        // to judge" case, and it must stay quiet and retryable.
12619        let empty = crate::community::roles::CommunityRoles::default();
12620        assert!(matches!(
12621            judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
12622            VendVerdict::Park(_)
12623        ));
12624
12625        // A channel our fold says is PUBLIC never heals — that's a spoof shape.
12626        let mut public_view = member_view.clone();
12627        if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12628            c.private = false;
12629        }
12630        assert!(matches!(
12631            judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
12632            VendVerdict::Refuse(_)
12633        ));
12634
12635        // An unknown channel parks (our fold may simply be behind), never refuses.
12636        assert!(matches!(
12637            judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
12638            VendVerdict::Park(_)
12639        ));
12640
12641        // Park the vend, then re-judge with a roster that still lacks our grant:
12642        // it must SURVIVE, not be discarded.
12643        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
12644        crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
12645        crate::db::community::save_community_v2(&member_view).unwrap();
12646        let session = crate::db::current_session();
12647        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12648        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
12649        assert_eq!(
12650            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
12651            1,
12652            "and stays parked for the next fold"
12653        );
12654
12655        // The fold catches up: our grant lands, so the same vend now adopts.
12656        let access = crate::community::roles::Role {
12657            role_id: "44".repeat(32),
12658            name: "mods".into(),
12659            position: u32::MAX - 1,
12660            permissions: crate::community::roles::Permissions::empty(),
12661            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12662            color: 0,
12663        };
12664        let folded = crate::community::roles::CommunityRoles {
12665            grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
12666            roles: vec![access],
12667        };
12668        crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
12669        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12670        let adopted = absorb_parked_channel_keys(&reloaded, &session);
12671        assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
12672
12673        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12674        let ch = after.channel(&priv_id).unwrap();
12675        assert_eq!(ch.key, Some(real_key), "adopted the vended key");
12676        assert_eq!(ch.epoch, Epoch(1));
12677        assert!(
12678            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12679            "and the park is discharged"
12680        );
12681    }
12682
12683    #[tokio::test]
12684    async fn a_vend_from_an_unentitled_sender_is_never_seated_but_survives_for_the_real_one() {
12685        // Vend rule 4: the VENDOR must be entitled too. Every other test sends as
12686        // the owner, which short-circuits the check one line above it, so the rule
12687        // itself has never been exercised. Both halves matter — a stranger must not
12688        // seat a key, and their row must not consume the slot either, which is the
12689        // suppression the candidate table exists to prevent.
12690        let (bed, owner, member) = TestBed::new();
12691        bed.swap_to(&owner);
12692        let community = create_community(&bed.relay, "Vendors", bed.relays.clone(), None).await.unwrap();
12693        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12694        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12695        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12696        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12697        let owner_hex = community.owner().unwrap().to_hex();
12698
12699        bed.swap_to(&member);
12700        let mut member_view = held.clone();
12701        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12702            c.key = None;
12703            c.epoch = Epoch(0);
12704        }
12705        crate::db::community::save_community_v2(&member_view).unwrap();
12706
12707        // WE are entitled (rule 3 passes, so the judge reaches rule 4). The vendor
12708        // below is not, and is not the owner.
12709        let access = crate::community::roles::Role {
12710            role_id: "66".repeat(32),
12711            name: "mods".into(),
12712            position: u32::MAX - 1,
12713            permissions: crate::community::roles::Permissions::empty(),
12714            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12715            color: 0,
12716        };
12717        let roster = crate::community::roles::CommunityRoles {
12718            grants: vec![crate::community::roles::MemberGrant {
12719                member: member.keys.public_key().to_hex(),
12720                role_ids: vec![access.role_id.clone()],
12721            }],
12722            roles: vec![access],
12723        };
12724        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
12725
12726        let stranger_hex = Keys::generate().public_key().to_hex();
12727        let junk = [0x11; 32];
12728        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &junk, &stranger_hex).unwrap();
12729
12730        let session = crate::db::current_session();
12731        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12732        assert!(
12733            absorb_parked_channel_keys(&reloaded, &session).is_empty(),
12734            "an unentitled vendor seats nothing"
12735        );
12736        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12737        assert_eq!(after.channel(&priv_id).unwrap().key, None, "the channel is still keyless");
12738        assert_eq!(
12739            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
12740            1,
12741            "parked, not refused — the vendor's own entitlement may simply not have folded here yet"
12742        );
12743
12744        // The entitled vendor's key lands despite the stranger's row sitting there.
12745        let real = [0x5a; 32];
12746        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &real, &owner_hex).unwrap();
12747        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12748        assert_eq!(
12749            absorb_parked_channel_keys(&reloaded, &session).len(),
12750            1,
12751            "the genuine vend is seated — a stranger cannot occupy the slot"
12752        );
12753        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12754        assert_eq!(after.channel(&priv_id).unwrap().key, Some(real), "and it is the ENTITLED vendor's key");
12755        assert!(
12756            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12757            "both candidates discharge once one is seated"
12758        );
12759    }
12760
12761    #[tokio::test]
12762    async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
12763        // Live cross-client finding: a peer that mints born-private channels at
12764        // epoch 0 vends epoch 0, which collides with our keyless cursor (also 0).
12765        // The monotonic guard (`new > current`) would refuse the only key we are
12766        // ever offered, and refuse it SILENTLY. First delivery is not a rotation.
12767        let (bed, owner, member) = TestBed::new();
12768        bed.swap_to(&owner);
12769        let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
12770        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12771        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12772        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12773        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12774        let vended = [0x5a; 32];
12775        let owner_hex = community.owner().unwrap().to_hex();
12776
12777        bed.swap_to(&member);
12778        // The member's view: channel known, keyless, parked at the epoch-0 cursor.
12779        let mut member_view = held.clone();
12780        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12781            c.key = None;
12782            c.epoch = Epoch(0);
12783        }
12784        crate::db::community::save_community_v2(&member_view).unwrap();
12785
12786        // Entitle them, then park a vend AT EPOCH 0 (what the peer actually sends).
12787        let access = crate::community::roles::Role {
12788            role_id: "77".repeat(32),
12789            name: "mods".into(),
12790            position: u32::MAX - 1,
12791            permissions: crate::community::roles::Permissions::empty(),
12792            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12793            color: 0,
12794        };
12795        let roster = crate::community::roles::CommunityRoles {
12796            grants: vec![crate::community::roles::MemberGrant {
12797                member: member.keys.public_key().to_hex(),
12798                role_ids: vec![access.role_id.clone()],
12799            }],
12800            roles: vec![access],
12801        };
12802        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
12803        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
12804
12805        let session = crate::db::current_session();
12806        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12807        let adopted = absorb_parked_channel_keys(&reloaded, &session);
12808        assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
12809
12810        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12811        let ch = after.channel(&priv_id).unwrap();
12812        assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
12813        assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
12814        assert!(
12815            !after.channel_read_coords(ch).is_empty(),
12816            "and the channel is readable — the whole point"
12817        );
12818        assert!(
12819            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12820            "the park is discharged"
12821        );
12822    }
12823
12824    #[tokio::test]
12825    async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
12826        // The channel head is MONOTONIC, so over-advancing it can never be walked
12827        // back: every genuine rotation afterwards lands at head+1, reads as stale,
12828        // and the channel dies for us with no heal path at all. An entitled
12829        // insider vending a garbage key costs isolation (accepted); one vending a
12830        // garbage EPOCH would cost the channel permanently, which is not.
12831        let (bed, owner, member) = TestBed::new();
12832        bed.swap_to(&owner);
12833        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
12834        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12835        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12836        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12837        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12838        let owner_hex = community.owner().unwrap().to_hex();
12839
12840        bed.swap_to(&member);
12841        let mut member_view = held.clone();
12842        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12843            c.key = None;
12844            c.epoch = Epoch(0);
12845        }
12846        crate::db::community::save_community_v2(&member_view).unwrap();
12847        let access = crate::community::roles::Role {
12848            role_id: "99".repeat(32),
12849            name: "mods".into(),
12850            position: u32::MAX - 1,
12851            permissions: crate::community::roles::Permissions::empty(),
12852            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
12853            color: 0,
12854        };
12855        let roster = crate::community::roles::CommunityRoles {
12856            grants: vec![crate::community::roles::MemberGrant {
12857                member: member.keys.public_key().to_hex(),
12858                role_ids: vec![access.role_id.clone()],
12859            }],
12860            roles: vec![access],
12861        };
12862        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
12863        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12864
12865        // Everything else about this vend is valid — only the epoch is absurd.
12866        assert!(matches!(
12867            judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
12868            VendVerdict::Refuse(_)
12869        ));
12870        // REFUSED, not parked: a row nothing can ever discharge is its own leak.
12871        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
12872        let session = crate::db::current_session();
12873        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
12874        assert!(
12875            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
12876            "and the row is discharged rather than parked forever"
12877        );
12878        // The head is untouched, so the genuine vend still lands afterwards.
12879        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12880        assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
12881        assert!(matches!(
12882            judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
12883            VendVerdict::Accept
12884        ));
12885    }
12886
12887    #[tokio::test]
12888    async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
12889        // The fold is the authority but runs later, so publishing alone leaves the
12890        // edit reading back stale — it looks like the rename silently failed.
12891        let (_tmp, _guard, _owner) = init_test_db();
12892        let relay = MemoryRelay::new();
12893        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
12894        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12895        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12896        let key_before = held.channel(&priv_id).unwrap().key;
12897
12898        let mut meta = held.channel(&priv_id).unwrap().metadata();
12899        meta.name = "staff".into();
12900        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
12901
12902        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12903        let ch = after.channel(&priv_id).unwrap();
12904        assert_eq!(ch.name, "staff", "the rename is visible immediately");
12905        assert!(ch.private, "and privacy survives the edit");
12906        assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
12907    }
12908
12909    #[tokio::test]
12910    async fn a_channel_rename_carries_its_companion_access_role() {
12911        let (_tmp, _guard, _owner) = init_test_db();
12912        let relay = MemoryRelay::new();
12913        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
12914        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12915        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12916        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12917
12918        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12919        let before = roster.channel_roles(&chan_hex);
12920        assert_eq!(before.len(), 1, "one companion role, minted at create");
12921        assert_eq!(before[0].name, "mods", "named after the channel it gates");
12922        let role_id = before[0].role_id.clone();
12923
12924        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12925        let mut meta = held.channel(&priv_id).unwrap().metadata();
12926        meta.name = "staff".into();
12927        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
12928
12929        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12930        let after = roster.channel_roles(&chan_hex);
12931        assert_eq!(after.len(), 1, "renamed in place, never duplicated");
12932        assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
12933        assert_eq!(after[0].name, "staff", "the access role followed the channel");
12934        assert_eq!(
12935            after[0].permissions,
12936            crate::community::roles::Permissions::empty(),
12937            "and still confers read access, never authority"
12938        );
12939    }
12940
12941    #[tokio::test]
12942    async fn a_customised_access_role_name_survives_a_channel_rename() {
12943        // The label is cosmetic — entitlement rides the scope. Overwriting a name
12944        // someone chose deliberately is the surprising half of "keep them in step".
12945        let (_tmp, _guard, _owner) = init_test_db();
12946        let relay = MemoryRelay::new();
12947        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
12948        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
12949        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12950        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12951
12952        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12953        let mut role = roster.channel_roles(&chan_hex)[0].clone();
12954        role.name = "Lab Insiders".into();
12955        set_role(&relay, &community, &role).await.unwrap();
12956        merge_local_roster(&cid_hex, Some(&role), None);
12957
12958        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12959        let mut meta = held.channel(&priv_id).unwrap().metadata();
12960        meta.name = "staff".into();
12961        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
12962
12963        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12964        assert_eq!(
12965            roster.channel_roles(&chan_hex)[0].name,
12966            "Lab Insiders",
12967            "a deliberate name is left alone"
12968        );
12969    }
12970
12971    #[tokio::test]
12972    async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
12973        // Parking is reachable by ANY npub that can gift-wrap us — the bundle
12974        // self-certifies and its inputs are public for a public community. With a
12975        // single slot per channel, a stranger could pre-park and the admin's real
12976        // vend would be a silent no-op, leaving the member keyless with no retry.
12977        // Candidates + judge-them-all is what closes that.
12978        let (bed, owner, member) = TestBed::new();
12979        bed.swap_to(&owner);
12980        let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
12981        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
12982        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12983        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
12984        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12985        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
12986        let owner_hex = community.owner().unwrap().to_hex();
12987
12988        bed.swap_to(&member);
12989        let mut member_view = held.clone();
12990        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
12991            c.key = None;
12992            c.epoch = Epoch(0);
12993        }
12994        crate::db::community::save_community_v2(&member_view).unwrap();
12995        let access = crate::community::roles::Role {
12996            role_id: "aa".repeat(32),
12997            name: "mods".into(),
12998            position: u32::MAX - 1,
12999            permissions: crate::community::roles::Permissions::empty(),
13000            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
13001            color: 0,
13002        };
13003        let roster = crate::community::roles::CommunityRoles {
13004            grants: vec![crate::community::roles::MemberGrant {
13005                member: member.keys.public_key().to_hex(),
13006                role_ids: vec![access.role_id.clone()],
13007            }],
13008            roles: vec![access],
13009        };
13010        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
13011
13012        // A stranger squats FIRST, at a higher epoch than the genuine vend.
13013        let stranger = Keys::generate().public_key().to_hex();
13014        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
13015        // The admin's real vend arrives after, at the true epoch.
13016        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
13017        assert_eq!(
13018            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
13019            2,
13020            "the squatter never displaces the genuine vend — both are candidates"
13021        );
13022
13023        let session = crate::db::current_session();
13024        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13025        let adopted = absorb_parked_channel_keys(&reloaded, &session);
13026        assert_eq!(adopted.len(), 1, "exactly one adoption");
13027
13028        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13029        let ch = after.channel(&priv_id).unwrap();
13030        assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
13031        assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
13032        assert!(
13033            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
13034            "and every candidate for the channel is discharged"
13035        );
13036    }
13037
13038    #[tokio::test]
13039    async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
13040        // With no access role folded, the retained-set filter matches NOBODY, so
13041        // the rotation would cut off every legitimately entitled member while the
13042        // Grant it published revoked nothing. Reachable with no attacker: the
13043        // channel was made on another admin's client and its role hasn't folded.
13044        let (_tmp, _guard, _owner) = init_test_db();
13045        let relay = MemoryRelay::new();
13046        let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
13047        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
13048        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13049        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13050        let before = held.channel(&priv_id).unwrap().epoch;
13051
13052        // Neither the cache nor the plane serves the access role — a withholding
13053        // relay, or a channel minted on another admin's client. (Wiping only the
13054        // cache is no longer enough: the revoke re-fetches authority first.)
13055        crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
13056        let mut blind = held.clone();
13057        blind.relays = vec!["wss://empty".into()];
13058        let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
13059            .await
13060            .unwrap_err();
13061        assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
13062
13063        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13064        assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
13065    }
13066
13067    // ── Live rekey-follow ────────────────────────────────────────────────────
13068
13069    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
13070    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
13071    /// a stranger for the authority test); `prev_key` is the root it claims to
13072    /// extend (mismatch → a fork).
13073    async fn publish_base_rotation(
13074        relay: &MemoryRelay,
13075        community: &CommunityV2,
13076        rotator: &Keys,
13077        recipients: &[PublicKey],
13078        new_root: &[u8; 32],
13079        prev_key: &[u8; 32],
13080    ) {
13081        let new_epoch = Epoch(community.root_epoch.0 + 1);
13082        let prev_epoch = community.root_epoch;
13083        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
13084        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
13085        let blobs: Vec<_> = recipients
13086            .iter()
13087            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
13088            .collect();
13089        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();
13090        for e in &events {
13091            relay.publish(e, &community.relays).await.unwrap();
13092        }
13093    }
13094
13095    /// Attach a Private channel (key + epoch) to a held community and persist it.
13096    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
13097        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
13098        crate::db::community::save_community_v2(community).unwrap();
13099    }
13100
13101    #[tokio::test]
13102    async fn follow_rekeys_is_a_noop_without_rotations() {
13103        let (_tmp, _guard, _owner) = init_test_db();
13104        let relay = MemoryRelay::new();
13105        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
13106        let session = crate::db::current_session();
13107        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13108        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
13109    }
13110
13111    #[tokio::test]
13112    async fn follow_rekeys_adopts_an_owner_base_rotation() {
13113        let (_tmp, _guard, owner) = init_test_db();
13114        let relay = MemoryRelay::new();
13115        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
13116        let new_root = [0xB1; 32];
13117        // Owner rotates the base to epoch 1, delivering the new root to me.
13118        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
13119
13120        let session = crate::db::current_session();
13121        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
13122        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
13123        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
13124        // The public channel now reads under the NEW root/epoch (its address moved).
13125        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
13126        let general = updated.channels[0].id;
13127        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
13128        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
13129    }
13130
13131    #[tokio::test]
13132    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
13133        let (_tmp, _guard, owner) = init_test_db();
13134        let relay = MemoryRelay::new();
13135        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
13136        let priv_id = ChannelId([0x33; 32]);
13137        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
13138
13139        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
13140        let new_key = [0x55; 32];
13141        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
13142        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
13143        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();
13144        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
13145        for e in &events {
13146            relay.publish(e, &community.relays).await.unwrap();
13147        }
13148
13149        let session = crate::db::current_session();
13150        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
13151        let ch = updated.channel(&priv_id).unwrap();
13152        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
13153        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
13154        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
13155    }
13156
13157    #[tokio::test]
13158    async fn follow_rekeys_ignores_a_non_owner_rotation() {
13159        // A member holds the community_root, so they can derive the rekey group key
13160        // and mint a rotation — but they aren't the owner, so it's not adopted.
13161        let (_tmp, _guard, _owner) = init_test_db();
13162        let relay = MemoryRelay::new();
13163        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
13164        let rogue = Keys::generate();
13165        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
13166
13167        let session = crate::db::current_session();
13168        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13169        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
13170    }
13171
13172    #[tokio::test]
13173    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
13174        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
13175        // extension — never adopted (would splice me onto an unrelated chain).
13176        let (_tmp, _guard, owner) = init_test_db();
13177        let relay = MemoryRelay::new();
13178        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
13179        // prev_key ≠ the real community_root → the continuity check reads Fork.
13180        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
13181
13182        let session = crate::db::current_session();
13183        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13184        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
13185    }
13186
13187    #[tokio::test]
13188    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
13189        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
13190        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
13191        let (_tmp, _guard, owner) = init_test_db();
13192        let relay = MemoryRelay::new();
13193        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
13194        let new_epoch = Epoch(1);
13195        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
13196        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
13197        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
13198        let other = Keys::generate();
13199        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
13200        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
13201        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
13202        relay.publish(&wrap, &community.relays).await.unwrap();
13203
13204        let session = crate::db::current_session();
13205        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
13206        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
13207    }
13208
13209    #[tokio::test]
13210    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
13211        // Realistic two-actor removal: the owner Refounds the base and delivers the
13212        // new root to a THIRD party, not the member — a complete rotation with no
13213        // blob for the member is a removal.
13214        let (bed, owner, member) = TestBed::new();
13215        bed.swap_to(&owner);
13216        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
13217        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13218
13219        bed.swap_to(&member);
13220        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13221        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
13222
13223        // Owner rotates, delivering only to a stranger (the member is dropped).
13224        bed.swap_to(&owner);
13225        let stranger = Keys::generate();
13226        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
13227
13228        // The member's follow concludes removal (a complete rotation without their blob).
13229        bed.swap_to(&member);
13230        let session = crate::db::current_session();
13231        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
13232        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
13233        assert!(follow.updated.is_none(), "a removed member adopts nothing");
13234    }
13235
13236    #[tokio::test]
13237    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
13238        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
13239        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
13240        // the base crate landed and was walked before the channel crates) must
13241        // still find them — the lookup fans across the archived roots, not just
13242        // the current one.
13243        let (_tmp, _guard, owner) = init_test_db();
13244        let relay = MemoryRelay::new();
13245        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
13246        let root0 = community.community_root;
13247        let priv_id = ChannelId([0x33; 32]);
13248        let key1 = [0x44; 32];
13249        add_private_channel(&mut community, priv_id, key1, Epoch(1));
13250
13251        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
13252        // root (root0), delivering the fresh key to me.
13253        let key2 = [0x55; 32];
13254        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13255        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
13256        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();
13257        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() {
13258            relay.publish(&e, &community.relays).await.unwrap();
13259        }
13260
13261        // Simulate the base having ALREADY advanced (the stranding order): the head
13262        // moved to a fresh root while root0 sits in the epoch-key archive (where
13263        // genesis put it).
13264        community.community_root = [0xB7; 32];
13265        community.root_epoch = Epoch(1);
13266        crate::db::community::save_community_v2(&community).unwrap();
13267
13268        let session = crate::db::current_session();
13269        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
13270        let ch = updated.channel(&priv_id).unwrap();
13271        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
13272        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
13273    }
13274
13275    #[tokio::test]
13276    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
13277        // A keyless private channel (announced by vsk-2, key not yet held) has no
13278        // chain, so its epoch is a scan cursor: a complete rotation that excludes
13279        // us advances the cursor (never a removal — we were never in); a later
13280        // rotation that includes us is the entry point.
13281        let (_tmp, _guard, owner) = init_test_db();
13282        let relay = MemoryRelay::new();
13283        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
13284        let priv_id = ChannelId([0x66; 32]);
13285        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() });
13286        crate::db::community::save_community_v2(&community).unwrap();
13287        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13288        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
13289
13290        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
13291        let stranger = Keys::generate();
13292        let key1 = [0x71; 32];
13293        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
13294        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
13295        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();
13296        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
13297            relay.publish(&e, &community.relays).await.unwrap();
13298        }
13299        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
13300        // recipient set is the CURRENT members).
13301        let key2 = [0x72; 32];
13302        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13303        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13304        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();
13305        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
13306            relay.publish(&e, &community.relays).await.unwrap();
13307        }
13308
13309        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
13310        // blob — adopt), because each real step re-loops.
13311        let session = crate::db::current_session();
13312        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
13313        let ch = updated.channel(&priv_id).unwrap();
13314        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
13315        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
13316    }
13317
13318    #[tokio::test]
13319    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
13320        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
13321        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
13322        // rotation keys members up; a mere keyholder's forgery never does.
13323        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13324        let (_tmp, _guard, _owner) = init_test_db();
13325        let relay = MemoryRelay::new();
13326        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
13327        let priv_id = ChannelId([0x88; 32]);
13328        let key1 = [0x91; 32];
13329        add_private_channel(&mut community, priv_id, key1, Epoch(1));
13330
13331        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
13332        let admin = Keys::generate();
13333        let role = Role::admin("aa".repeat(32));
13334        let roster = CommunityRoles {
13335            roles: vec![role.clone()],
13336            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
13337        };
13338        seed_roster_with_heads(&community, &roster, 1_000);
13339
13340        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
13341        let key2 = [0x92; 32];
13342        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13343        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13344        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
13345        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
13346        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() {
13347            relay.publish(&e, &community.relays).await.unwrap();
13348        }
13349        let session = crate::db::current_session();
13350        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
13351        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
13352
13353        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
13354        let rogue = Keys::generate();
13355        let key3 = [0x93; 32];
13356        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
13357        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
13358        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
13359        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
13360            relay.publish(&e, &updated.relays).await.unwrap();
13361        }
13362        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
13363        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
13364    }
13365
13366    #[tokio::test]
13367    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
13368        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
13369        // target. An equal-rank bit-holder's complete rotation that skips my blob
13370        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
13371        // two-account bed: the follower must be a NON-owner admin.
13372        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13373        let (bed, owner, member) = TestBed::new();
13374        bed.swap_to(&owner);
13375        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
13376
13377        // The MEMBER's device: holds the community + the private channel, with a
13378        // persisted roster granting the member AND a peer the same Admin role.
13379        bed.swap_to(&member);
13380        let mut held = community.clone();
13381        let priv_id = ChannelId([0xAB; 32]);
13382        let key1 = [0xA1; 32];
13383        add_private_channel(&mut held, priv_id, key1, Epoch(1));
13384        let peer = Keys::generate();
13385        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13386        let role = Role::admin("bb".repeat(32));
13387        let roster = CommunityRoles {
13388            roles: vec![role.clone()],
13389            grants: vec![
13390                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
13391                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
13392            ],
13393        };
13394        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
13395
13396        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
13397        let key2 = [0xA2; 32];
13398        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13399        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
13400        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();
13401        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() {
13402            bed.relay.publish(&e, &held.relays).await.unwrap();
13403        }
13404        let session = crate::db::current_session();
13405        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
13406        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
13407        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
13408        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
13409
13410        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
13411        let key3 = [0xA3; 32];
13412        let stranger = Keys::generate();
13413        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();
13414        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() {
13415            bed.relay.publish(&e, &held.relays).await.unwrap();
13416        }
13417        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
13418        let updated = follow.updated.expect("the owner's removal folds");
13419        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
13420    }
13421
13422    #[tokio::test]
13423    async fn converting_a_public_channel_to_private_is_refused() {
13424        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
13425        // the producer refuses the flag flip, so no reader is left unkeyable.
13426        let (_tmp, _guard, _owner) = init_test_db();
13427        let relay = MemoryRelay::new();
13428        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
13429        let general = community.channels[0].id;
13430        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
13431        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
13432        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
13433        // A rename of the same public channel still works.
13434        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
13435        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
13436    }
13437
13438    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
13439    /// `removed_at` — simulating a sibling device having left that community.
13440    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
13441        let doc = super::super::list::CommunityList {
13442            entries: vec![],
13443            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
13444            extra: Default::default(),
13445        };
13446        // A sibling device writes fragments, like any §8 client.
13447        for (i, frag) in super::super::list_frag::fragment(&doc).iter().enumerate() {
13448            let event = super::super::list_frag::build_fragment_event_keys(me, frag, i, now_ms() / 1000).unwrap();
13449            relay.publish(&event, relays).await.unwrap();
13450        }
13451    }
13452
13453    #[tokio::test]
13454    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
13455        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
13456        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
13457        // which would silently undo the leave everywhere.
13458        let (_tmp, _guard, me) = init_test_db();
13459        let relay = MemoryRelay::new();
13460        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
13461        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
13462
13463        // A sibling leaves X: a remote tombstone strictly newer than X's add.
13464        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
13465
13466        // Now join a different community Y → republish(just_joined = Y).
13467        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
13468        republish_community_list(&relay, Some(y.id())).await.unwrap();
13469
13470        // X must still read as LEFT in the published list; Y must be live.
13471        let list = fetch_fragments(&relay, &x.relays).await.unwrap().unwrap().list;
13472        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
13473        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
13474    }
13475
13476    #[tokio::test]
13477    async fn sync_tears_down_a_community_a_sibling_left() {
13478        // W1 (receive side): a community still held locally that the synced 13302
13479        // shows tombstoned-and-not-live is torn down, so a leave propagates.
13480        let (_tmp, _guard, me) = init_test_db();
13481        let relay = MemoryRelay::new();
13482        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
13483        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
13484        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
13485
13486        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
13487        sync_community_list(&relay, &x.relays).await.unwrap();
13488        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
13489    }
13490
13491    #[tokio::test]
13492    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
13493        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
13494        // the tombstone), so a later sync keeps it.
13495        let (_tmp, _guard, me) = init_test_db();
13496        let relay = MemoryRelay::new();
13497        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
13498        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
13499        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
13500        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
13501        // Re-record the membership (a re-join) → live entry at now >> 1.
13502        republish_community_list(&relay, Some(x.id())).await.unwrap();
13503        sync_community_list(&relay, &x.relays).await.unwrap();
13504        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
13505    }
13506
13507    #[tokio::test]
13508    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
13509        // W2: a transient fetch failure during republish must not drive the
13510        // replaceable-event write (which would drop other entries / regress seeds).
13511        let (_tmp, _guard, _me) = init_test_db();
13512        let good = MemoryRelay::new();
13513        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
13514        assert!(fetch_fragments(&good, &community.relays).await.unwrap().is_some());
13515
13516        // A transport whose fetch always errors: republish must bail, publishing nothing.
13517        struct FetchErrors;
13518        #[async_trait::async_trait]
13519        impl Transport for FetchErrors {
13520            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
13521            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
13522                panic!("republish must NOT publish when the remote fetch failed");
13523            }
13524            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
13525                Ok(())
13526            }
13527            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
13528                Err("relay unreachable".to_string())
13529            }
13530        }
13531        // Returns Ok (best-effort) but must not have published (the panic guards it).
13532        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
13533    }
13534
13535    #[tokio::test]
13536    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
13537        // B1 regression: refound_community's recipient set = memberlist. A member
13538        // the owner GRANTED a role to but who never left a (surviving) Guestbook
13539        // Join — a lurking admin, or one whose Join aged out of the window — must
13540        // still be a rekey recipient, or the Refounding SEVERS them. The folded
13541        // roster's granted members are the consensus-complete backstop.
13542        let (_tmp, _guard, owner) = init_test_db();
13543        let relay = MemoryRelay::new();
13544        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
13545
13546        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
13547        let lurker = Keys::generate();
13548        let rid = "b1".repeat(32);
13549        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
13550        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
13551
13552        // memberlist includes the lurker purely via the roster backstop.
13553        let members = memberlist(&relay, &community).await.unwrap();
13554        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
13555
13556        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
13557        let banned_grantee = Keys::generate();
13558        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
13559        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
13560        let members = memberlist(&relay, &community).await.unwrap();
13561        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
13562        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
13563
13564        // And the Refounding actually delivers the new root to the lurker.
13565        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
13566        assert_eq!(refounded.root_epoch, Epoch(1));
13567        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
13568        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
13569        let rotations = rekey::collect_rotations(&chunks);
13570        let lurker_x = lurker.public_key().to_bytes();
13571        let delivered = rotations.iter().any(|r| {
13572            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
13573        });
13574        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
13575    }
13576
13577    #[tokio::test]
13578    async fn the_memberlist_pages_past_a_guestbook_flood() {
13579        // The roleless-member half of B1: >500 Guestbook events must not evict an
13580        // honest member's Join from the counted set (an insider can flood throwaway
13581        // Joins to force exactly this). The pager sees them all.
13582        let (_tmp, _guard, _owner) = init_test_db();
13583        let relay = MemoryRelay::new();
13584        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
13585        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13586
13587        // An honest member's Join (oldest), then 600 throwaway Joins on top.
13588        let honest = Keys::generate();
13589        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
13590        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
13591        relay.publish(&w, &community.relays).await.unwrap();
13592        for i in 0..600u64 {
13593            let throwaway = Keys::generate();
13594            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
13595            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
13596            relay.publish(&w, &community.relays).await.unwrap();
13597        }
13598
13599        let members = memberlist(&relay, &community).await.unwrap();
13600        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
13601    }
13602
13603    #[tokio::test]
13604    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
13605        // An insider floods the next-epoch rekey address (community_root-derived,
13606        // so any member can seal there) with >200 junk 3303s to push the owner's
13607        // genuine rotation out of a single fetch window. The paginated fetch must
13608        // still recover it and adopt.
13609        let (_tmp, _guard, owner) = init_test_db();
13610        let relay = MemoryRelay::new();
13611        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
13612        let new_root = [0xD9; 32];
13613        let new_epoch = Epoch(1);
13614        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
13615
13616        // The GENUINE owner rotation lands first (oldest).
13617        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
13618
13619        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
13620        // (newer), burying the genuine one past the 200 newest.
13621        let rogue = Keys::generate();
13622        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
13623        for i in 0..260u64 {
13624            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
13625            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();
13626            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
13627            relay.publish(&wrap, &community.relays).await.unwrap();
13628        }
13629
13630        let session = crate::db::current_session();
13631        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
13632        assert_eq!(updated.root_epoch, Epoch(1));
13633        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
13634    }
13635
13636    #[tokio::test]
13637    async fn a_swap_during_create_private_channel_lands_in_the_creating_account() {
13638        // create_private_channel publishes the key crate, then the channel
13639        // edition, then whole-row-saves. A swap in that window used to abandon
13640        // the whole thing — leaving the key crate published to relays but no
13641        // local channel to open it, which is the worse of the two outcomes.
13642        //
13643        // The operation is pinned to the account that started it, so it now
13644        // finishes into that account's storage, and the account swapped in gets
13645        // nothing.
13646        let (bed, owner, _member) = TestBed::new();
13647        bed.swap_to(&owner);
13648        let creator = crate::db::current_session();
13649        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
13650        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
13651
13652        // The key-crate publish inside create swaps the account mid-flight.
13653        let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
13654        create_private_channel(&swap_relay, &community, "ghost").await.unwrap();
13655
13656        let mine = crate::db::with_session(creator, async {
13657            crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
13658        })
13659        .await;
13660        assert_eq!(mine.channels.len(), before + 1, "the channel is minted for the account that asked");
13661        assert!(mine.channels.iter().any(|c| c.name == "ghost"), "and it is the one requested");
13662    }
13663
13664    #[tokio::test]
13665    async fn an_uncited_admin_rotation_is_not_adopted() {
13666        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
13667        // authority action, so a just-demoted admin's rotation is never honored by
13668        // a lagging client." An uncited rotation is skipped entirely — neither
13669        // adopted nor allowed to conclude a removal.
13670        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13671        let (_tmp, _guard, _owner) = init_test_db();
13672        let relay = MemoryRelay::new();
13673        let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
13674        let priv_id = ChannelId([0x8A; 32]);
13675        let key1 = [0x93; 32];
13676        add_private_channel(&mut community, priv_id, key1, Epoch(1));
13677
13678        let admin = Keys::generate();
13679        let role = Role::admin("cf".repeat(32));
13680        let roster = CommunityRoles {
13681            roles: vec![role.clone()],
13682            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
13683        };
13684        seed_roster_with_heads(&community, &roster, 1_000);
13685
13686        let key2 = [0x94; 32];
13687        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13688        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13689        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
13690        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
13691        // Authorized admin, correct continuity, my blob present — but NO citation.
13692        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
13693            relay.publish(&e, &community.relays).await.unwrap();
13694        }
13695
13696        let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
13697        assert!(out.updated.is_none(), "an uncited rotation is not adopted");
13698
13699        // The SAME rotation, cited, is adopted — proving the refusal was the
13700        // citation and not the rank or the continuity.
13701        let cited = my_authority_citation(&community, &admin.public_key());
13702        assert!(cited.is_some(), "the seeded head yields a citation");
13703        let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
13704        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() {
13705            relay.publish(&e, &community.relays).await.unwrap();
13706        }
13707        let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
13708        assert!(out.updated.is_some(), "the cited rotation IS adopted");
13709    }
13710
13711    #[tokio::test]
13712    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
13713        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
13714        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
13715        // Every follower must converge on the SAME key (the lexicographically
13716        // lowest), so the community never permanently forks. (Retaining the losing
13717        // fork's key for its race-window messages needs a multi-key-per-epoch
13718        // archive — a deferred refinement shared with v1; convergence, the
13719        // security-critical property, is what this pins.)
13720        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
13721        let (_tmp, _guard, _owner) = init_test_db();
13722        let relay = MemoryRelay::new();
13723        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
13724        let priv_id = ChannelId([0xC0; 32]);
13725        let key1 = [0xC1; 32];
13726        add_private_channel(&mut community, priv_id, key1, Epoch(1));
13727
13728        // Two admins (a, b) both hold the Admin role; I hold the channel key.
13729        let (a, b) = (Keys::generate(), Keys::generate());
13730        let role = Role::admin("ce".repeat(32));
13731        let roster = CommunityRoles {
13732            roles: vec![role.clone()],
13733            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
13734        };
13735        seed_roster_with_heads(&community, &roster, 1_000);
13736
13737        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
13738        // same prevcommit — a genuine same-epoch fork.
13739        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
13740        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
13741        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
13742        let key_a = [0x0A; 32];
13743        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
13744        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
13745            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
13746            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() {
13747                relay.publish(&e, &community.relays).await.unwrap();
13748            }
13749        }
13750
13751        let session = crate::db::current_session();
13752        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
13753        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
13754        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
13755
13756        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
13757        let mut peer = community.clone();
13758        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
13759            c.key = Some(key1);
13760            c.epoch = Epoch(1);
13761        }
13762        // Re-run the same fold from the peer's identical starting point → same winner.
13763        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
13764        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
13765    }
13766
13767    #[tokio::test]
13768    async fn create_private_channel_refuses_a_member_without_manage_channels() {
13769        // The local mirror of the reader's gate: an unauthorized member is refused
13770        // BEFORE any publish (no floor pollution, no orphan key crate).
13771        let (bed, owner, member) = TestBed::new();
13772        bed.swap_to(&owner);
13773        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
13774        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13775
13776        bed.swap_to(&member);
13777        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13778        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
13779        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
13780        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
13781        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
13782        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
13783    }
13784
13785    // ── Audit regressions ────────────────────────────────────────────────────
13786
13787    #[tokio::test]
13788    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
13789        // The eclipse: community_id commits only to (owner, salt) — both semi-public
13790        // — so a forged invite pairs the REAL triple with an attacker root, and every
13791        // plane derives from it. The join-time owner-genesis check must refuse.
13792        let (bed, owner, member) = TestBed::new();
13793        bed.swap_to(&owner);
13794        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
13795
13796        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
13797        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
13798        forged.community_root = fake.clone();
13799        for ch in &mut forged.channels {
13800            ch.key = fake.clone();
13801        }
13802        let attacker = Keys::generate();
13803        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
13804        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13805
13806        bed.swap_to(&member);
13807        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13808        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
13809        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
13810        assert!(
13811            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
13812            "a rejected join persists nothing"
13813        );
13814    }
13815
13816    #[tokio::test]
13817    async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
13818        // CORD-06 compaction re-wraps CURRENT heads with their original signatures,
13819        // so a rotated plane whose metadata an admin last edited carries no
13820        // owner-signed vsk-0. The join anchor there is the community-bound metadata
13821        // head plus any owner-signed edition under the same root.
13822        let (bed, owner, member) = TestBed::new();
13823        bed.swap_to(&owner);
13824        let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
13825        let general = community.channels[0].id;
13826
13827        let rotated = rotate_view(&community, [0x5A; 32], 1);
13828        let admin = Keys::generate();
13829        publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
13830        publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
13831
13832        bed.swap_to(&member);
13833        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
13834        let joined = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13835        assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
13836    }
13837
13838    #[tokio::test]
13839    async fn only_an_actual_join_publishes_a_guestbook_join() {
13840        // A Guestbook Join is a member's own word that they JOINED. A re-accept of
13841        // a held community and a cross-device key sync (announce_join=false) must
13842        // both stay silent — each re-publish renders as "<user> has joined" spam.
13843        let (bed, owner, member) = TestBed::new();
13844        bed.swap_to(&owner);
13845        let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
13846
13847        let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
13848        async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
13849            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
13850            relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
13851        }
13852        let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; // the owner's creation Join
13853
13854        bed.swap_to(&member);
13855        let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
13856        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13857        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
13858
13859        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13860        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
13861
13862        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13863        crate::db::community::delete_community(&cid_hex).unwrap();
13864        // A key sync is not a membership event, and it is not a membership
13865        // RECORD either: the entry was just read from the very list a republish
13866        // would rewrite. A device adopting N entries used to rebuild and
13867        // publish the whole document N times over, concurrently, on boot.
13868        // 13302 is REPLACEABLE, so a republish swaps the stored event rather
13869        // than adding one — the id is the tell, and it always changes on a
13870        // real republish because the list is NIP-44 sealed with a random nonce.
13871        async fn list_id(relay: &MemoryRelay, me: &PublicKey, relays: &[String]) -> Option<String> {
13872            let q = Query { kinds: vec![crate::community::v2::kind::COMMUNITY_LIST_FRAG], authors: vec![me.to_hex()], ..Default::default() };
13873            let mut evs = relay.fetch(&q, relays).await.unwrap_or_default();
13874            evs.sort_by_key(|e| e.created_at.as_secs());
13875            evs.last().map(|e| e.id.to_hex())
13876        }
13877        let my_pk = me_pk().unwrap();
13878        let list_before = list_id(&bed.relay, &my_pk, &bed.relays).await;
13879        accept_bundle(&bed.relay, &bundle, None, false).await.unwrap();
13880        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
13881        assert_eq!(
13882            list_id(&bed.relay, &my_pk, &bed.relays).await,
13883            list_before,
13884            "adopting an entry FROM the list must not republish the list"
13885        );
13886
13887        // A genuine join still records, so the storm fix can't have muted the
13888        // thing the republish exists for.
13889        crate::db::community::delete_community(&cid_hex).unwrap();
13890        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
13891        assert_ne!(
13892            list_id(&bed.relay, &my_pk, &bed.relays).await,
13893            list_before,
13894            "a real join still records the membership across devices"
13895        );
13896    }
13897
13898    #[tokio::test]
13899    async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
13900        // The fallback's second half is load-bearing: a community-bound metadata
13901        // head alone is self-signable by anyone who knows the (public) community_id.
13902        let (bed, owner, member) = TestBed::new();
13903        bed.swap_to(&owner);
13904        let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
13905
13906        let rotated = rotate_view(&community, [0x5B; 32], 1);
13907        let attacker = Keys::generate();
13908        publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
13909
13910        bed.swap_to(&member);
13911        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
13912        let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
13913        assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
13914    }
13915
13916    #[tokio::test]
13917    async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
13918        // The fallback applies to rotated planes only: at epoch 0 the spec guarantees
13919        // an owner-signed genesis, so owner material without it stays insufficient.
13920        let (bed, owner, member) = TestBed::new();
13921        bed.swap_to(&owner);
13922        let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
13923        let general = community.channels[0].id;
13924
13925        let mut fake = community.clone();
13926        fake.community_root = [0x5C; 32]; // epoch stays 0
13927        let admin = Keys::generate();
13928        publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
13929        publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
13930
13931        bed.swap_to(&member);
13932        let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
13933        let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
13934        assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
13935    }
13936
13937    #[tokio::test]
13938    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
13939        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
13940        // joiner addresses it at a plane only the attacker reads. The owner's genuine
13941        // public:false edition must override it on follow.
13942        let (_tmp, _guard, _owner) = init_test_db();
13943        let relay = MemoryRelay::new();
13944        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
13945        let general = community.channels[0].id;
13946        let mut poisoned = community.clone();
13947        poisoned.channels[0].private = true;
13948        poisoned.channels[0].key = Some([0x66; 32]);
13949        crate::db::community::save_community_v2(&poisoned).unwrap();
13950
13951        let healed = follow_control(&relay, &poisoned).await.unwrap().expect("healed");
13952        let ch = healed.channel(&general).unwrap();
13953        assert!(!ch.private, "the owner's public declaration overrides the bundle");
13954        assert_eq!(ch.key, None, "a healed public channel derives from the root");
13955    }
13956
13957    #[tokio::test]
13958    async fn a_deleted_channel_does_not_resurrect_on_reload() {
13959        // save_community_v2 must prune orphan channel rows, or a control-follow delete
13960        // reappears (with a stale key) on the next reload.
13961        let (_tmp, _guard, owner) = init_test_db();
13962        let relay = MemoryRelay::new();
13963        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
13964        let extra = ChannelId([0x77; 32]);
13965        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
13966        let with_extra = follow_control(&relay, &community).await.unwrap().unwrap();
13967        assert!(with_extra.channel(&extra).is_some());
13968        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
13969        let after = follow_control(&relay, &with_extra).await.unwrap().unwrap();
13970        assert!(after.channel(&extra).is_none());
13971
13972        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13973        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
13974        assert_eq!(reloaded.channels.len(), 1);
13975    }
13976
13977    #[tokio::test]
13978    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
13979        // channel_id is the sole DB primary key, so a bundle/replay reusing another
13980        // community's channel_id must NOT overwrite that row. It's skipped (not an
13981        // error — erroring would wedge all of this community's control persistence).
13982        let (_tmp, _guard, _owner) = init_test_db();
13983        let relay = MemoryRelay::new();
13984        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
13985        let a_channel = a.channels[0].id;
13986        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
13987        let b_channel = b.channels[0].id;
13988        // B's set includes a phantom whose id collides with A's channel.
13989        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() });
13990
13991        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
13992        // A's channel row is untouched.
13993        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
13994        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
13995        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
13996        // B keeps its own channel but never acquired a row for the foreign id.
13997        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
13998        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
13999        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
14000    }
14001
14002    /// A single relay that CAPS every query below the page size (modelling a real
14003    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
14004    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
14005    /// truncates the whole set), which is why a MemoryRelay flood test gives false
14006    /// confidence about the production `LiveTransport` behaviour.
14007    struct CappedRelay {
14008        events: Vec<Event>,
14009        cap: usize,
14010    }
14011    #[async_trait::async_trait]
14012    impl Transport for CappedRelay {
14013        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
14014        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14015            Ok(())
14016        }
14017        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14018            Ok(())
14019        }
14020        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
14021            let mut m: Vec<Event> = self
14022                .events
14023                .iter()
14024                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
14025                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
14026                .cloned()
14027                .collect();
14028            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
14029            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
14030            Ok(m)
14031        }
14032    }
14033
14034    #[tokio::test]
14035    async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
14036        // CORD-06 §3: a Refounder that cannot fold every Control Event must abort.
14037        // `until` is inclusive, so a page-wide block of same-second wraps is a wall
14038        // no cursor steps past — everything older (the genesis editions, a Banlist)
14039        // is unreachable. Compacting THAT view carries only what was read into the
14040        // new epoch, dropping the rest for every member, permanently. Any member can
14041        // build the wall: the plane key comes from the community root they hold.
14042        let (_tmp, _guard, _owner) = init_test_db();
14043        let memory = MemoryRelay::new();
14044        let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
14045        // Post-split the plane is staff-write-only, so the wall is built with the
14046        // write group (any control_root holder can still flood; the pager
14047        // property under test is unchanged).
14048        let control = control::ControlPlane::of(&community).write_group().unwrap();
14049
14050        let rogue = Keys::generate();
14051        let mut events: Vec<Event> = Vec::new();
14052        for i in 0..FOLLOW_PAGE {
14053            let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
14054            let rumor = control::build_edition_rumor(
14055                rogue.public_key(),
14056                vsk::CHANNEL_METADATA,
14057                &[0xAB; 32],
14058                1,
14059                None,
14060                &content,
14061                9_000,
14062                None,
14063            );
14064            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
14065            events.push(w);
14066        }
14067        let relay = CappedRelay { events, cap: FOLLOW_PAGE };
14068
14069        let err = refound_community(&relay, &community, &[])
14070            .await
14071            .expect_err("a plane that can't be read whole must never be compacted");
14072        assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
14073    }
14074
14075    #[tokio::test]
14076    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
14077        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
14078        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
14079        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
14080        // timestamps so `until = now` includes everything and the walk is deterministic.
14081        let (_tmp, _guard, owner) = init_test_db();
14082        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
14083        let g = control::genesis(&owner, meta, 1_000).unwrap();
14084        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
14085
14086        let control = control::ControlPlane::of(&community).write_group().unwrap();
14087        let rogue = Keys::generate();
14088        let mut events: Vec<Event> = g.wraps.to_vec();
14089        for i in 0..250u64 {
14090            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
14091            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
14092            events.push(wrap);
14093        }
14094        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
14095        let relay = CappedRelay { events, cap: 100 };
14096        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
14097        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
14098    }
14099
14100    #[tokio::test]
14101    async fn accept_parked_invite_joins_from_the_stored_bundle() {
14102        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
14103        // from the stored bundle (re-verifying the owner root over the network).
14104        let (bed, owner, member) = TestBed::new();
14105        bed.swap_to(&owner);
14106        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
14107        let general = community.channels[0].id;
14108        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
14109        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14110        let bundle_json = serde_json::to_string(&bundle).unwrap();
14111        let inviter_hex = owner.keys.public_key().to_hex();
14112
14113        bed.swap_to(&member);
14114        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
14115        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
14116        assert!(joined.identity.verify());
14117        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
14118        // The join seeded the verified fold as the member's initial floor, so their
14119        // first follow can't roll below the state the join just showed.
14120        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14121        assert!(
14122            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
14123            "the joiner's control floor is seeded from the join-time fold"
14124        );
14125
14126        // The Guestbook memberlist now folds both participants.
14127        bed.swap_to(&owner);
14128        let members = memberlist(&bed.relay, &community).await.unwrap();
14129        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
14130    }
14131
14132    #[tokio::test]
14133    async fn accept_parked_invite_rejects_a_forged_root() {
14134        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
14135        // accept — the shared accept path re-verifies the owner root, so a parked invite
14136        // gets the same eclipse protection as a live one.
14137        let (_tmp, _guard, _owner) = init_test_db();
14138        let relay = MemoryRelay::new();
14139        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
14140        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
14141        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
14142        forged.community_root = fake.clone();
14143        for ch in &mut forged.channels {
14144            ch.key = fake.clone();
14145        }
14146        let bundle_json = serde_json::to_string(&forged).unwrap();
14147
14148        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
14149        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
14150    }
14151
14152    #[test]
14153    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
14154        // The protocol discriminator the facade list/accept relies on: a v2 bundle
14155        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
14156        // one does not, so a parked invite routes to the right accept path.
14157        let owner = Keys::generate();
14158        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
14159        let hex = crate::simd::hex::bytes_to_hex_32;
14160        let v2 = invite::CommunityInvite {
14161            community_id: hex(&identity.community_id.0),
14162            owner: hex(&identity.owner_xonly),
14163            owner_salt: hex(&identity.owner_salt),
14164            community_root: hex(&[0x11; 32]),
14165            root_epoch: 0,
14166            control_pk: None,
14167            channels: vec![],
14168            relays: vec!["wss://r".into()],
14169            name: "V2".into(),
14170            icon: None,
14171            expires_at: None,
14172            creator_npub: None,
14173            label: None,
14174            extra: Default::default(),
14175        };
14176        let v2_json = serde_json::to_string(&v2).unwrap();
14177        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
14178        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
14179        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
14180    }
14181
14182    #[tokio::test]
14183    async fn verify_rejects_a_cross_community_owner_edition_replay() {
14184        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
14185        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
14186        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
14187        // a different eid — is not a valid proof of T's root. This is why "any owner
14188        // edition" is unsound and the eid==community_id genesis pin is required.
14189        let (_tmp, _guard, owner) = init_test_db();
14190
14191        // Community X (real), owned by `owner`.
14192        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
14193        let x_control = control::split_write_group(&gx.control_root, &gx.community_root, &gx.identity.community_id, Epoch(0));
14194        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
14195
14196        // Forged community T: the real owner triple but an ATTACKER-chosen root.
14197        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
14198        let fake_root = [0xEE; 32];
14199        let t = CommunityV2 {
14200            identity: t_identity,
14201            community_root: fake_root,
14202            root_epoch: Epoch(0),
14203            control_pk: None,
14204            control_root: None,
14205            name: "T".into(),
14206            description: None,
14207            icon: None,
14208            banner: None,
14209            meta_custom: None,
14210            meta_extra: Default::default(),
14211            relays: vec!["wss://r".into()],
14212            channels: vec![],
14213            dissolved: false,
14214            created_at_ms: 0,
14215        };
14216        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
14217        // controls the fake root, so they can derive its control group key).
14218        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
14219        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
14220        let relay = MemoryRelay::new();
14221        relay.publish(&replayed, &t.relays).await.unwrap();
14222
14223        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
14224        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
14225    }
14226
14227    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
14228    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
14229    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
14230    /// it never touches real accounts. Run explicitly:
14231    /// ```sh
14232    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
14233    /// ```
14234    #[tokio::test]
14235    #[ignore = "hits a real relay over the network"]
14236    async fn live_smoke_create_send_fetch_on_a_real_relay() {
14237        use crate::community::transport::LiveTransport;
14238        use nostr_sdk::prelude::ToBech32;
14239
14240        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
14241        let relays = vec![relay.clone()];
14242
14243        // Isolated account + data dir (a fresh throwaway key — never a real account).
14244        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
14245        crate::db::close_database();
14246        crate::db::clear_id_caches();
14247        let tmp = tempfile::tempdir().unwrap();
14248        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
14249        // into elsewhere; otherwise a fresh throwaway.
14250        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
14251            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
14252            Err(_) => Keys::generate(),
14253        };
14254        let npub = keys.public_key().to_bech32().unwrap();
14255        // Off by default (never leak secrets from a committed test); set
14256        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
14257        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
14258            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
14259        }
14260        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
14261        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
14262        crate::db::set_current_account(npub.clone()).unwrap();
14263        crate::db::init_database(&npub).unwrap();
14264        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
14265        crate::state::set_my_public_key(keys.public_key());
14266        println!("[smoke] throwaway identity {npub}");
14267
14268        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
14269        let client = crate::nostr_client_builder().build();
14270        client.add_managed_relay(relay.as_str()).await.ok();
14271        client.connect().await;
14272        crate::state::set_nostr_client(client);
14273        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
14274
14275        // Create → send → fetch-back → verify.
14276        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
14277        let general = community.channels[0].id;
14278        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
14279
14280        let text = "hello from a Vector Concord v2 live smoke test";
14281        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
14282        println!("[smoke] sent message {sent_id}");
14283
14284        // Give the relay a moment to store + be ready to serve it.
14285        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
14286
14287        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
14288        let texts: Vec<String> = page
14289            .iter()
14290            .filter_map(|f| match &f.event {
14291                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
14292                _ => None,
14293            })
14294            .collect();
14295        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
14296        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
14297
14298        // Mint a shareable v2 link (the thing a bot hands out).
14299        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
14300        println!("[smoke] invite link: {}", link.url);
14301        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
14302    }
14303
14304    #[tokio::test]
14305    async fn chat_ops_react_edit_delete_round_trip() {
14306        let (bed, owner, _member) = TestBed::new();
14307        bed.swap_to(&owner);
14308        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
14309        let general = community.channels[0].id;
14310        let me_hex = owner.keys.public_key().to_hex();
14311
14312        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
14313        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
14314            .await
14315            .unwrap();
14316        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
14317        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
14318
14319        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14320        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
14321        let mut saw = (false, false, false);
14322        for f in &page {
14323            match &f.event {
14324                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
14325                    assert_eq!(emoji, ":fire:");
14326                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
14327                    saw.0 = true;
14328                }
14329                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
14330                    assert_eq!(new_content, "edited");
14331                    saw.1 = true;
14332                }
14333                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
14334                _ => {}
14335            }
14336        }
14337        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
14338    }
14339
14340    #[tokio::test]
14341    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
14342        let (bed, owner, _member) = TestBed::new();
14343        bed.swap_to(&owner);
14344        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
14345        let general = community.channels[0].id;
14346        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
14347
14348        // A live subscriber sees the 21059 wrap and it opens as Typing…
14349        let mut sub = bed.relay.subscribe(Query {
14350            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
14351            authors: vec![group.pk_hex()],
14352            ..Default::default()
14353        });
14354        send_typing(&bed.relay, &community, &general).await.unwrap();
14355        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
14356        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
14357            Ok(ChatEvent::Typing { opened }) => opened,
14358            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
14359        };
14360
14361        // …while nothing durable is stored (relays never keep the ephemeral tier),
14362        // so channel history stays free of typing noise…
14363        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14364        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
14365
14366        // …and no scrub key is retained (there is no durable wrap to ever delete).
14367        assert!(
14368            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
14369            "ephemeral sends must not retain scrub keys"
14370        );
14371    }
14372
14373    #[tokio::test]
14374    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
14375        let (bed, owner, _member) = TestBed::new();
14376        bed.swap_to(&owner);
14377        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
14378        let general = community.channels[0].id;
14379        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
14380
14381        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
14382
14383        // Retained: the row maps the rumor id to the exact published wrap, holds the
14384        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
14385        let (keys, outer_hex, relays) =
14386            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
14387        assert_eq!(relays, community.relays);
14388        let wrap_query = Query {
14389            kinds: vec![stream::KIND_WRAP],
14390            authors: vec![group.pk_hex()],
14391            ..Default::default()
14392        };
14393        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
14394        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
14395        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
14396
14397        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
14398        let me_hex = owner.keys.public_key().to_hex();
14399        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
14400            .await
14401            .unwrap();
14402        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
14403
14404        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
14405        // scrubs the wrap off the relay via the retained key, then consumes the row.
14406        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
14407        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
14408        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
14409        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
14410    }
14411
14412    #[tokio::test]
14413    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
14414        let (bed, owner, _member) = TestBed::new();
14415        bed.swap_to(&owner);
14416        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
14417        let general = community.channels[0].id;
14418        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
14419
14420        // Simulate a pre-retention / other-device send: our message on the relay,
14421        // but no local mapping row.
14422        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
14423        crate::db::community::delete_message_key(&id).unwrap();
14424        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
14425
14426        // A stranger member's message rides the same channel.
14427        let mkeys = Keys::generate();
14428        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
14429        let foreign_id = rumor.id.unwrap().to_hex();
14430        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
14431        bed.relay.publish(&fw, &community.relays).await.unwrap();
14432
14433        // One history open re-derives the mapping for the OWN message…
14434        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14435        let (keys, _outer, relays) =
14436            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
14437        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
14438        assert_eq!(relays, community.relays);
14439
14440        // …and never manufactures one for a foreign author.
14441        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
14442
14443        // The healed row is a working full delete: the shared path scrubs the wrap.
14444        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
14445        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14446        assert!(
14447            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
14448            "healed message scrubbed from the relay"
14449        );
14450    }
14451
14452    #[tokio::test]
14453    async fn send_chat_message_threads_the_reply_and_extra_tags() {
14454        let (bed, owner, _member) = TestBed::new();
14455        bed.swap_to(&owner);
14456        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
14457        let general = community.channels[0].id;
14458        let me_hex = owner.keys.public_key().to_hex();
14459
14460        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
14461        let imeta = nostr_sdk::prelude::Tag::custom(
14462            "imeta",
14463            ["url https://e/blob".to_string(), "m image/png".to_string()],
14464        );
14465        let child_id = send_chat_message(
14466            &bed.relay, &community, &general, "child",
14467            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
14468        )
14469        .await
14470        .unwrap();
14471
14472        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
14473        let child = page
14474            .iter()
14475            .find_map(|f| match &f.event {
14476                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
14477                _ => None,
14478            })
14479            .expect("the reply message round-trips");
14480        let reply = child.1.as_ref().expect("the reply reference is carried");
14481        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
14482        assert_eq!(reply.author, Some(owner.keys.public_key()));
14483        assert!(
14484            child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
14485            "the imeta attachment tag rides the rumor verbatim"
14486        );
14487    }
14488
14489    #[tokio::test]
14490    async fn a_kick_needs_kick_authority_and_removes_the_target() {
14491        let (bed, owner, member) = TestBed::new();
14492        bed.swap_to(&owner);
14493        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
14494
14495        // The target announces a Join (as an accepted invite would).
14496        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14497        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
14498        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
14499        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14500        let before = memberlist(&bed.relay, &community).await.unwrap();
14501        assert!(before.contains(&member.keys.public_key()), "the join lands first");
14502
14503        // An unprivileged member's kick of the owner is refused locally…
14504        bed.swap_to(&member);
14505        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
14506        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
14507
14508        // …and the owner (supreme, no grant needed) kicks the member out.
14509        bed.swap_to(&owner);
14510        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
14511        let after = memberlist(&bed.relay, &community).await.unwrap();
14512        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
14513        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
14514    }
14515
14516    #[tokio::test]
14517    async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
14518        // The self-eviction race: on a REJOIN the guestbook store starts empty while the
14519        // control fold has already re-derived the member's old ban mark, so the MEMBERLIST
14520        // legitimately excludes them for that window. A stale Kick landing there used to
14521        // read as an authorized eviction and the client nuked its own community.
14522        let (bed, owner, member) = TestBed::new();
14523        bed.swap_to(&owner);
14524        let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
14525        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14526        let (o, m) = (owner.keys.public_key(), member.keys.public_key());
14527        let join = |at: u64, id: u8| guestbook::GuestbookEvent {
14528            rumor_id: [id; 32],
14529            entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
14530        };
14531        let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
14532            rumor_id: [id; 32],
14533            entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
14534        };
14535
14536        // An authorized kick after their join stands.
14537        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
14538        assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
14539
14540        // A rejoin supersedes it — latest entry wins (CORD-02 §5).
14541        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
14542        assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
14543
14544        // The catch-up window itself: nothing folded yet decides nothing.
14545        crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
14546        assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
14547
14548        // And the memberlist is NOT a substitute: with the store empty it excludes them,
14549        // which is exactly the false positive this verdict replaced.
14550        assert!(
14551            !stored_memberlist(&community).unwrap().contains(&m),
14552            "the memberlist excludes an un-caught-up member — why it can't gate a kick"
14553        );
14554    }
14555
14556    /// Seed a roster the way production does: `follow_control` writes the roster
14557    /// AND the folded edition heads in one pass, so a citation against a grant is
14558    /// resolvable. Seeding the roster alone yields a client that can never satisfy
14559    /// any `vac` — a shape no v2 production path produces.
14560    fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
14561        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14562        crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
14563        for g in &roster.grants {
14564            let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
14565            let eid = super::super::derive::grant_locator(community.id(), &m);
14566            let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
14567            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
14568        }
14569    }
14570
14571    /// Publish an edition CITING a specific grant version (CORD-04 §5's `vac`).
14572    async fn publish_grant_citing(
14573        relay: &MemoryRelay,
14574        community: &CommunityV2,
14575        signer: &Keys,
14576        member: &PublicKey,
14577        role_ids: Vec<String>,
14578        version: u64,
14579        citation: Option<&crate::community::edition::AuthorityCitation>,
14580    ) {
14581        let group = control::ControlPlane::of(&community).write_group().unwrap();
14582        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
14583        let prev = head_hash_on_relay(relay, community, &eid).await;
14584        let grant = MemberGrant { member: member.to_hex(), role_ids };
14585        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
14586        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
14587        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
14588        relay.publish(&wrap, &community.relays).await.unwrap();
14589    }
14590
14591    #[tokio::test]
14592    async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
14593        // CORD-04 §5 on the CONTROL PLANE: "a verifier won't act on the edition
14594        // until it has synced at least that Grant". The citation resolves against
14595        // the heads THIS fold accepted — an external floor would refuse every
14596        // non-owner edition on a bootstrap and the roster could never fold.
14597        let (bed, owner, admin) = TestBed::new();
14598        bed.swap_to(&owner);
14599        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
14600        let admin_pk = admin.keys.public_key();
14601        let rid = "c3".repeat(32);
14602        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
14603        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
14604
14605        // The admin grants a bystander, citing NOTHING.
14606        // A LOWER role (position 5) — an admin at position 1 may grant beneath
14607        // themselves but never at their own rank (equal cannot act on equal).
14608        let low_rid = "c4".repeat(32);
14609        let mut low = admin_role(&low_rid, Permissions::admin().0);
14610        low.position = 5;
14611        publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
14612
14613        let bystander = Keys::generate().public_key();
14614        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
14615        let view = fetch_authority(&bed.relay, &community).await;
14616        assert!(
14617            !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
14618            "an uncited non-owner edition is not folded"
14619        );
14620        // The owner's own editions still fold — supreme cites nothing.
14621        assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
14622
14623        // Same edition, now citing the admin's real grant: honored. (follow_control
14624        // is what PERSISTS the folded heads a citation is built from.)
14625        let _ = follow_control(&bed.relay, &community).await;
14626        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
14627        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14628        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
14629        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
14630        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
14631        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
14632
14633        let view = fetch_authority(&bed.relay, &community).await;
14634        assert!(
14635            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
14636            "the same edition WITH its synced citation folds"
14637        );
14638    }
14639
14640    #[tokio::test]
14641    async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
14642        // The invite is deliberately ungated, so a fresh Join can arrive seconds
14643        // BEFORE the unban edition. It must reach the store (banned = a fold
14644        // verdict, not a storage verdict) so the unban resurrects the member —
14645        // dropped at ingest, they stayed invisible forever.
14646        let (bed, owner, member) = TestBed::new();
14647        bed.swap_to(&owner);
14648        let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
14649        let member_pk = member.keys.public_key();
14650        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14651
14652        // Locally banned (edition folded at t=1000s), with the outliving mark.
14653        crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
14654        crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
14655
14656        // Their Join lands 60s after the ban mark, while the banlist still says banned.
14657        let join = guestbook::GuestbookEvent {
14658            rumor_id: [9u8; 32],
14659            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
14660        };
14661        assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
14662        assert!(
14663            !stored_memberlist(&community).unwrap().contains(&member_pk),
14664            "while banned, the fold keeps them out"
14665        );
14666
14667        // The unban folds: same store, no refetch needed — the Join resurrects them.
14668        crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
14669        assert!(
14670            stored_memberlist(&community).unwrap().contains(&member_pk),
14671            "after the unban the raced Join makes them a member again"
14672        );
14673    }
14674
14675    #[tokio::test]
14676    async fn a_stale_root_admin_write_is_refused_not_misdirected() {
14677        // The ban→unban race: a Ban's refound buries the old root over several
14678        // publishes while a concurrently-issued command still holds the
14679        // pre-commit struct. That unban used to land on the buried control
14680        // plane — "succeeding" while no reader would ever fold it — and a
14681        // concurrently-minted invite stranded its joiner on the dead epoch.
14682        let (bed, owner, member) = TestBed::new();
14683        bed.swap_to(&owner);
14684        let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
14685        let member_pk = member.keys.public_key();
14686
14687        set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
14688        let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
14689
14690        // The stale-struct unban is REFUSED (retryable), never misdirected.
14691        let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
14692        assert!(err.contains("re-founded"), "unban: {err}");
14693        // A stale invite must not mint dead-epoch key material.
14694        let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
14695        assert!(err.contains("re-founded"), "invite: {err}");
14696        // Neither is a kick allowed to ride the buried guestbook.
14697        let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
14698        assert!(err.contains("re-founded"), "kick: {err}");
14699
14700        // The retry path: a fresh load lands the unban on the LIVING plane.
14701        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14702        set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
14703        let view = fetch_authority(&bed.relay, &fresh).await;
14704        assert!(view.banned.is_empty(), "the retried unban actually unbans");
14705    }
14706
14707    /// [`MemoryRelay`] with a cooperative yield before every call, so two units
14708    /// driven by one `join!` genuinely interleave at each network boundary — on
14709    /// the single-threaded test runtime the bare relay resolves without yielding,
14710    /// the first unit runs to completion, and the race never happens.
14711    struct YieldyRelay<'a>(&'a MemoryRelay);
14712    #[async_trait::async_trait]
14713    impl Transport for YieldyRelay<'_> {
14714        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
14715            tokio::task::yield_now().await;
14716            self.0.publish(event, relays).await
14717        }
14718        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14719            tokio::task::yield_now().await;
14720            self.0.fetch(query, relays).await
14721        }
14722        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14723            tokio::task::yield_now().await;
14724            self.0.fetch_plane(plane, query, relays).await
14725        }
14726        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
14727            tokio::task::yield_now().await;
14728            self.0.publish_durable(event, relays).await
14729        }
14730    }
14731
14732    #[tokio::test]
14733    async fn concurrent_bans_compose_instead_of_erasing_each_other() {
14734        // The SDK spawns a handler task per message, so a spam wave runs N ban
14735        // units concurrently. Unserialized, both read the pre-sibling banlist,
14736        // the second head erases the first target at every reader, and the
14737        // refound compacts that erasure into the new epoch permanently.
14738        let (bed, owner, _member) = TestBed::new();
14739        bed.swap_to(&owner);
14740        let community = create_community(&bed.relay, "Wave", bed.relays.clone(), None).await.unwrap();
14741        let a = Keys::generate().public_key();
14742        let b = Keys::generate().public_key();
14743
14744        let yieldy = YieldyRelay(&bed.relay);
14745        let (wave_a, wave_b) = ([a], [b]);
14746        let (ra, rb) = tokio::join!(
14747            set_members_banned(&yieldy, community.id(), &wave_a, true),
14748            set_members_banned(&yieldy, community.id(), &wave_b, true),
14749        );
14750        ra.unwrap();
14751        rb.unwrap();
14752
14753        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14754        assert_eq!(fresh.root_epoch, Epoch(2), "each single ban still performed its own read cut");
14755        let view = fetch_authority(&bed.relay, &fresh).await;
14756        assert!(
14757            view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
14758            "both racing bans landed: {:?}",
14759            view.banned
14760        );
14761    }
14762
14763    #[tokio::test]
14764    async fn bans_stacked_behind_a_rotation_coalesce_into_one_read_cut() {
14765        // Human-speed serial bans: the leader's read cut holds the lock for a
14766        // whole rotation, so bans arriving meanwhile deposit intents. The next
14767        // lock holder drains ALL of them — one edition, one rotation, however
14768        // many stacked. Worst case for a burst of N is 2 rotations, never N.
14769        let (bed, owner, _member) = TestBed::new();
14770        bed.swap_to(&owner);
14771        let community = create_community(&bed.relay, "Stack", bed.relays.clone(), None).await.unwrap();
14772        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14773        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
14774        let (a, b, c, d) = (
14775            Keys::generate().public_key(),
14776            Keys::generate().public_key(),
14777            Keys::generate().public_key(),
14778            Keys::generate().public_key(),
14779        );
14780
14781        let yieldy = YieldyRelay(&bed.relay);
14782        let (wa, wb, wc, wd) = ([a], [b], [c], [d]);
14783        // The first future drains itself and rotates; the other three stack
14784        // behind that rotation and coalesce under the next leader.
14785        let (ra, rb, rc, rd) = tokio::join!(
14786            set_members_banned(&yieldy, community.id(), &wa, true),
14787            set_members_banned(&yieldy, community.id(), &wb, true),
14788            set_members_banned(&yieldy, community.id(), &wc, true),
14789            set_members_banned(&yieldy, community.id(), &wd, true),
14790        );
14791        ra.unwrap();
14792        rb.unwrap();
14793        rc.unwrap();
14794        rd.unwrap();
14795
14796        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14797        assert_eq!(fresh.root_epoch, Epoch(2), "leader rotation + ONE coalesced rotation, never four");
14798        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
14799        assert_eq!(version, 2, "two editions total: the leader's and the coalesced drain's");
14800        let view = fetch_authority(&bed.relay, &fresh).await;
14801        for pk in [a, b, c, d] {
14802            assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
14803        }
14804    }
14805
14806    #[tokio::test]
14807    async fn a_stacked_ban_and_unban_coalesce_in_arrival_order() {
14808        // The drain applies intents in arrival order over the evolving list, so
14809        // ban(X) … unban(X) stacked behind one rotation nets X out entirely and
14810        // only the net newly-banned member costs a read cut.
14811        let (bed, owner, _member) = TestBed::new();
14812        bed.swap_to(&owner);
14813        let community = create_community(&bed.relay, "Net", bed.relays.clone(), None).await.unwrap();
14814        let x = Keys::generate().public_key();
14815        let y = Keys::generate().public_key();
14816
14817        let yieldy = YieldyRelay(&bed.relay);
14818        let (wx, wy) = ([x], [y]);
14819        let (rx1, ry, rx2) = tokio::join!(
14820            set_members_banned(&yieldy, community.id(), &wx, true),  // leader: rotates on X
14821            set_members_banned(&yieldy, community.id(), &wy, true),  // stacked
14822            set_members_banned(&yieldy, community.id(), &wx, false), // stacked: nets X back out
14823        );
14824        rx1.unwrap();
14825        ry.unwrap();
14826        rx2.unwrap();
14827
14828        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14829        assert_eq!(fresh.root_epoch, Epoch(2), "X's rotation + one coalesced rotation for Y");
14830        let view = fetch_authority(&bed.relay, &fresh).await;
14831        assert!(!view.banned.contains(&x.to_hex()), "the stacked unban netted X out");
14832        assert!(view.banned.contains(&y.to_hex()), "Y's stacked ban landed");
14833    }
14834
14835    #[tokio::test]
14836    async fn a_second_member_folds_every_stacked_ban() {
14837        // Derek's observer divergence, inverted: the whole point of composing the
14838        // batch correctly is that an INDEPENDENT reader folding the same plane
14839        // agrees. A fresh joiner (separate account DB, post-rotation bundle) must
14840        // see every stacked ban — not just the leader's own echo.
14841        let (bed, owner, member) = TestBed::new();
14842        bed.swap_to(&owner);
14843        let community = create_community(&bed.relay, "Agree", bed.relays.clone(), None).await.unwrap();
14844        let a = Keys::generate().public_key();
14845        let b = Keys::generate().public_key();
14846
14847        let yieldy = YieldyRelay(&bed.relay);
14848        let (wa, wb) = ([a], [b]);
14849        let (ra, rb) = tokio::join!(
14850            set_members_banned(&yieldy, community.id(), &wa, true),
14851            set_members_banned(&yieldy, community.id(), &wb, true),
14852        );
14853        ra.unwrap();
14854        rb.unwrap();
14855
14856        // Post-rotation bundle, exactly what a live link would serve now.
14857        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14858        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14859        let bundle_json = serde_json::to_string(&bundle).unwrap();
14860
14861        bed.swap_to(&member);
14862        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14863        let _ = follow_control(&bed.relay, &joined).await;
14864        let view = fetch_authority(&bed.relay, &joined).await;
14865        assert!(
14866            view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
14867            "an independent reader folds BOTH stacked bans: {:?}",
14868            view.banned
14869        );
14870    }
14871
14872    #[tokio::test]
14873    async fn a_net_zero_batch_publishes_no_edition() {
14874        // ban(X) + unban(X) stacked in one drain nets to the original list — the
14875        // batch must not spend an edition (or a rotation) publishing a no-op.
14876        let (bed, owner, _member) = TestBed::new();
14877        bed.swap_to(&owner);
14878        let community = create_community(&bed.relay, "Noop", bed.relays.clone(), None).await.unwrap();
14879        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14880        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
14881        let x = Keys::generate().public_key();
14882
14883        // The leader's slot is taken by a harmless real ban so X's ban+unban land
14884        // in ONE coalesced drain together.
14885        let decoy = Keys::generate().public_key();
14886        let yieldy = YieldyRelay(&bed.relay);
14887        let (wd, wx) = ([decoy], [x]);
14888        let (rd, r1, r2) = tokio::join!(
14889            set_members_banned(&yieldy, community.id(), &wd, true),
14890            set_members_banned(&yieldy, community.id(), &wx, true),
14891            set_members_banned(&yieldy, community.id(), &wx, false),
14892        );
14893        rd.unwrap();
14894        r1.unwrap();
14895        r2.unwrap();
14896
14897        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14898        assert_eq!(fresh.root_epoch, Epoch(1), "only the decoy's rotation — the net-zero drain rotated nothing");
14899        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
14900        assert_eq!(version, 1, "only the decoy's edition — the net-zero drain published nothing");
14901        let view = fetch_authority(&bed.relay, &fresh).await;
14902        assert!(!view.banned.contains(&x.to_hex()));
14903        assert!(view.banned.contains(&decoy.to_hex()));
14904    }
14905
14906    /// Fetches fine, refuses every publish — the dead-network shape.
14907    struct BrokenPublish<'a>(&'a MemoryRelay);
14908    #[async_trait::async_trait]
14909    impl Transport for BrokenPublish<'_> {
14910        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14911            tokio::task::yield_now().await;
14912            Err("relay refused".to_string())
14913        }
14914        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14915            tokio::task::yield_now().await;
14916            self.0.fetch(query, relays).await
14917        }
14918        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
14919            tokio::task::yield_now().await;
14920            self.0.fetch_plane(plane, query, relays).await
14921        }
14922        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
14923            tokio::task::yield_now().await;
14924            Err("relay refused".to_string())
14925        }
14926    }
14927
14928    #[tokio::test]
14929    async fn a_failed_publish_answers_every_stacked_caller_and_never_hangs() {
14930        // The batch's shared fate: when the edition can't land, every stacked
14931        // intent must resolve with the error — a waiter left parked on a dead
14932        // oneshot would hang a moderation bot forever.
14933        let (bed, owner, _member) = TestBed::new();
14934        bed.swap_to(&owner);
14935        let community = create_community(&bed.relay, "Dead", bed.relays.clone(), None).await.unwrap();
14936        let a = Keys::generate().public_key();
14937        let b = Keys::generate().public_key();
14938
14939        let broken = BrokenPublish(&bed.relay);
14940        let (wa, wb) = ([a], [b]);
14941        let (ra, rb) = tokio::time::timeout(
14942            std::time::Duration::from_secs(30),
14943            async {
14944                tokio::join!(
14945                    set_members_banned(&broken, community.id(), &wa, true),
14946                    set_members_banned(&broken, community.id(), &wb, true),
14947                )
14948            },
14949        )
14950        .await
14951        .expect("stacked callers must resolve, never hang");
14952        assert!(ra.is_err(), "the leader surfaced the publish failure");
14953        assert!(rb.is_err(), "the stacked caller surfaced it too");
14954
14955        // Nothing landed: a later fold shows no bans.
14956        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14957        let view = fetch_authority(&bed.relay, &fresh).await;
14958        assert!(view.banned.is_empty(), "no half-published state: {:?}", view.banned);
14959        assert_eq!(fresh.root_epoch, Epoch(0), "no rotation on a failed edition");
14960    }
14961
14962    #[tokio::test]
14963    async fn a_poison_intent_fails_alone_not_its_batchmates() {
14964        // Per-intent verdicts: an add that busts the 500 ceiling answers ITS
14965        // caller with the refusal while its batch-mates publish normally.
14966        let (bed, owner, _member) = TestBed::new();
14967        bed.swap_to(&owner);
14968        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
14969        // Pre-fill to 498 so the leader lands 499, the first stacked ban lands
14970        // exactly 500, and the second stacked ban would be 501.
14971        let filler: Vec<String> = (0..498).map(|i| format!("{i:064x}")).collect();
14972        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14973        set_banlist(&bed.relay, &held, &filler).await.unwrap();
14974        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14975        crate::db::community::set_community_banlist(&cid_hex, &filler, 1).unwrap();
14976
14977        let (a, x, y) = (Keys::generate().public_key(), Keys::generate().public_key(), Keys::generate().public_key());
14978        let yieldy = YieldyRelay(&bed.relay);
14979        let (wa, wx, wy) = ([a], [x], [y]);
14980        let (ra, rx, ry) = tokio::join!(
14981            set_members_banned(&yieldy, community.id(), &wa, true),
14982            set_members_banned(&yieldy, community.id(), &wx, true),
14983            set_members_banned(&yieldy, community.id(), &wy, true),
14984        );
14985        ra.unwrap();
14986        rx.unwrap();
14987        let err = ry.unwrap_err();
14988        assert!(err.contains("ceiling"), "the over-cap intent failed alone: {err}");
14989
14990        let list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
14991        assert_eq!(list.len(), 500, "the batch published up to the ceiling");
14992        assert!(list.contains(&x.to_hex()) && !list.contains(&y.to_hex()));
14993    }
14994
14995    #[tokio::test]
14996    async fn ban_many_is_one_edition_one_refound_for_the_whole_wave() {
14997        let (bed, owner, _member) = TestBed::new();
14998        bed.swap_to(&owner);
14999        let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
15000        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15001        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
15002        let wave: Vec<PublicKey> = (0..3).map(|_| Keys::generate().public_key()).collect();
15003
15004        set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap();
15005
15006        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15007        assert_eq!(fresh.root_epoch, Epoch(1), "one refound for the whole wave, not one per target");
15008        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
15009        assert_eq!(version, 1, "one banlist edition carried the whole wave");
15010        let view = fetch_authority(&bed.relay, &fresh).await;
15011        for pk in &wave {
15012            assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
15013        }
15014
15015        // The reductive mirror: unban two of three in one edition, no read cut.
15016        set_members_banned(&bed.relay, community.id(), &wave[..2], false).await.unwrap();
15017        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15018        assert_eq!(fresh.root_epoch, Epoch(1), "an unban rotates nothing");
15019        let view = fetch_authority(&bed.relay, &fresh).await;
15020        assert_eq!(view.banned.len(), 1, "reductive: exactly the un-unbanned target remains");
15021        assert!(view.banned.contains(&wave[2].to_hex()));
15022    }
15023
15024    #[tokio::test]
15025    async fn a_batch_past_the_banlist_cap_refuses_before_publishing() {
15026        let (bed, owner, _member) = TestBed::new();
15027        bed.swap_to(&owner);
15028        let community = create_community(&bed.relay, "Cap", bed.relays.clone(), None).await.unwrap();
15029        let wave: Vec<PublicKey> = (0..=crate::community::v2::roles::MAX_BANLIST).map(|_| Keys::generate().public_key()).collect();
15030
15031        let err = set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap_err();
15032        assert!(err.contains("ceiling"), "refused at the cap: {err}");
15033        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15034        assert_eq!(fresh.root_epoch, Epoch(0), "nothing rotated");
15035        let view = fetch_authority(&bed.relay, &fresh).await;
15036        assert!(view.banned.is_empty(), "nothing published");
15037    }
15038
15039    #[tokio::test]
15040    async fn an_uncited_kick_from_an_admin_is_not_honored() {
15041        // CORD-04 §5: a non-owner authority action must name the Grant it acts
15042        // under, and the reader refuses until it holds that Grant. Emitting the
15043        // `vac` without checking it buys nothing — a demoted admin's kick would
15044        // still land on any client that hadn't synced the demotion.
15045        let (bed, owner, member) = TestBed::new();
15046        bed.swap_to(&owner);
15047        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
15048        let admin = Keys::generate();
15049        let member_pk = member.keys.public_key();
15050        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
15051
15052        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15053        let view = fetch_authority(&bed.relay, &community).await;
15054        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
15055
15056        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
15057        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
15058        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
15059        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
15060
15061        let joined = guestbook::GuestbookEvent {
15062            rumor_id: [1u8; 32],
15063            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
15064        };
15065        let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
15066            rumor_id: [id; 32],
15067            entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
15068        };
15069        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
15070        let empty_bans = std::collections::BTreeSet::new();
15071        let empty_marks = std::collections::BTreeMap::new();
15072        let fold = |evs: &[guestbook::GuestbookEvent]| {
15073            fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
15074        };
15075
15076        assert!(
15077            fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
15078            "an uncited kick from an admin is not honored"
15079        );
15080        assert!(
15081            !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
15082            "the same kick WITH its synced citation removes them"
15083        );
15084    }
15085
15086    #[tokio::test]
15087    async fn kicking_an_admin_strips_their_roles_first() {
15088        // CORD-04 §6 composition: Role Removal THEN the directive. Kicking without the
15089        // strip leaves the target out of the memberlist but still holding every
15090        // management bit, so every client keeps honoring their control editions.
15091        let (bed, owner, member) = TestBed::new();
15092        bed.swap_to(&owner);
15093        let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
15094        let member_pk = member.keys.public_key();
15095        let member_hex = member_pk.to_hex();
15096        let owner_hex = owner.keys.public_key().to_hex();
15097
15098        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15099        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
15100
15101        kick_member(&bed.relay, &community, &member_pk).await.unwrap();
15102
15103        let view = fetch_authority(&bed.relay, &community).await;
15104        assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
15105        assert!(
15106            !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
15107            "a kicked admin holds no bit"
15108        );
15109        assert!(
15110            !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
15111            "and the directive still removed them"
15112        );
15113    }
15114
15115    #[tokio::test]
15116    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
15117        let (bed, owner, member) = TestBed::new();
15118        bed.swap_to(&owner);
15119        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
15120        let member_pk = member.keys.public_key();
15121        let member_hex = member_pk.to_hex();
15122        let owner_hex = owner.keys.public_key().to_hex();
15123
15124        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15125        let view = fetch_authority(&bed.relay, &community).await;
15126        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
15127        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
15128
15129        // A second grant (any device) converges on the SAME role entity — and a
15130        // repeat is a no-op, not a version bump.
15131        let second = Keys::generate().public_key();
15132        grant_admin(&bed.relay, &community, &second).await.unwrap();
15133        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15134        let view = fetch_authority(&bed.relay, &community).await;
15135        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
15136        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
15137        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
15138        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
15139
15140        // Revoke strips ONLY the admin role and de-authorizes.
15141        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
15142        let view = fetch_authority(&bed.relay, &community).await;
15143        assert!(!view.roles.is_admin(&member_hex), "revoked");
15144        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
15145        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
15146    }
15147
15148    #[tokio::test]
15149    async fn follow_control_persists_the_roster_for_sync_local_reads() {
15150        let (bed, owner, member) = TestBed::new();
15151        bed.swap_to(&owner);
15152        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
15153        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15154        let member_hex = member.keys.public_key().to_hex();
15155        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
15156
15157        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
15158        follow_control(&bed.relay, &community).await.unwrap();
15159        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15160        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
15161
15162        // A withholding relay serves nothing — an empty fold raises no gap flag, and
15163        // the stored roster must be RETAINED, never wiped.
15164        let withholding = MemoryRelay::new();
15165        let _ = follow_control(&withholding, &community).await;
15166        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15167        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
15168
15169        // A real revocation (a NEWER grant edition) does replace it.
15170        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
15171        follow_control(&bed.relay, &community).await.unwrap();
15172        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15173        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
15174    }
15175
15176    #[tokio::test]
15177    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
15178        let (bed, owner, member) = TestBed::new();
15179        bed.swap_to(&owner);
15180        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
15181
15182        bed.swap_to(&member);
15183        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
15184        assert!(err.contains("owner"), "refused before any publish: {err}");
15185
15186        // The deterministic admin-role entity stays unsquatted — the owner's later
15187        // legitimate mint is version 1 and folds cleanly.
15188        bed.swap_to(&owner);
15189        let view = fetch_authority(&bed.relay, &community).await;
15190        assert!(view.roles.roles.is_empty(), "no role edition landed");
15191        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
15192        let view = fetch_authority(&bed.relay, &community).await;
15193        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
15194    }
15195
15196    #[tokio::test]
15197    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
15198        let (bed, owner, member) = TestBed::new();
15199        bed.swap_to(&owner);
15200        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
15201        let member_pk = member.keys.public_key();
15202
15203        // The member already holds a Mod role, granted through the real send path
15204        // (so this device's floors track both entities).
15205        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
15206        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
15207        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
15208
15209        // A relay that withholds the control plane must refuse the merge — a blind
15210        // push would erase the Mod role at a higher version.
15211        let withholding = MemoryRelay::new();
15212        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
15213        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
15214
15215        // Against the full relay the merge preserves the Mod role.
15216        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
15217        let view = fetch_authority(&bed.relay, &community).await;
15218        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
15219        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
15220        assert!(grant.role_ids.contains(&mod_rid));
15221    }
15222
15223    #[tokio::test]
15224    async fn fetch_authority_reflects_a_granted_admin() {
15225        let (bed, owner, member) = TestBed::new();
15226        bed.swap_to(&owner);
15227        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
15228        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
15229        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
15230        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
15231
15232        let view = fetch_authority(&bed.relay, &community).await;
15233        let member_hex = member.keys.public_key().to_hex();
15234        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
15235        assert!(
15236            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
15237            "an ADMIN_ALL grant carries KICK"
15238        );
15239        assert!(view.banned.is_empty());
15240    }
15241
15242    // ── Pins (CORD-04 §7) — fold, authority, and the silent Admin widening ──
15243
15244    /// The full wire round trip: a real message pinned into a published
15245    /// edition, folded by the control follow, persisted, and read back proven.
15246    #[tokio::test]
15247    async fn a_pin_edition_folds_persists_and_reads_back() {
15248        let (_tmp, _guard, _owner) = init_test_db();
15249        let relay = MemoryRelay::new();
15250        let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
15251        let general = community.channels[0].id;
15252        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15253
15254        send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
15255        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15256        let opened = page
15257            .iter()
15258            .find_map(|f| match &f.event {
15259                ChatEvent::Message { opened, .. } => Some(opened.clone()),
15260                _ => None,
15261            })
15262            .expect("the sent message reads back");
15263
15264        let ch = community.channels[0].clone();
15265        let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
15266        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15267        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
15268
15269        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
15270        publish_control_edition(&relay, &community, vsk::PINS, &eid, &content).await.unwrap();
15271        follow_control(&relay, &community).await.unwrap();
15272
15273        let read = read_channel_pins(&community, &general).unwrap();
15274        assert!(!read.sealed);
15275        assert!(read.version >= 1, "the folded head persisted");
15276        assert_eq!(read.pins.len(), 1);
15277        assert_eq!(read.pins[0].content, "pin-worthy");
15278        assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
15279    }
15280
15281    #[tokio::test]
15282    async fn a_fresh_join_sees_pins_and_the_banlist_without_waiting_for_a_follow() {
15283        // The join's verification walk already folds the control plane, so pins and
15284        // the banlist must persist FROM THE JOIN — deferring them to the follow
15285        // worker's queued re-walk left a fresh member pins-blind (and rendering a
15286        // banned author) for the whole queue delay.
15287        let (bed, owner, member) = TestBed::new();
15288        bed.swap_to(&owner);
15289        let community = create_community(&bed.relay, "PinJoin", bed.relays.clone(), None).await.unwrap();
15290        let general = community.channels[0].id;
15291        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15292
15293        send_message(&bed.relay, &community, &general, "pin-worthy").await.unwrap();
15294        let page = fetch_channel(&bed.relay, &community, &general, 10).await.unwrap();
15295        let opened = page
15296            .iter()
15297            .find_map(|f| match &f.event {
15298                ChatEvent::Message { opened, .. } => Some(opened.clone()),
15299                _ => None,
15300            })
15301            .expect("the sent message reads back");
15302        let ch = community.channels[0].clone();
15303        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
15304        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15305        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
15306        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
15307        publish_control_edition(&bed.relay, &community, vsk::PINS, &eid, &content).await.unwrap();
15308        let spammer = Keys::generate().public_key().to_hex();
15309        set_banlist(&bed.relay, &community, std::slice::from_ref(&spammer)).await.unwrap();
15310        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
15311        let bundle_json = serde_json::to_string(&bundle).unwrap();
15312
15313        bed.swap_to(&member);
15314        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15315        // Deliberately NO follow_control here — the join alone must suffice.
15316        let read = read_channel_pins(&joined, &general).unwrap();
15317        assert!(!read.sealed);
15318        assert_eq!(read.pins.len(), 1, "pins visible the instant the join returns");
15319        assert_eq!(read.pins[0].content, "pin-worthy");
15320        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
15321        let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15322        assert!(banlist.contains(&spammer), "the banlist is enforced from the join, not the first follow");
15323    }
15324
15325    /// CORD-04 §5: a pins edition from an author holding no PIN_MESSAGES never
15326    /// becomes the head — the fold's authority gate covers the new entity.
15327    #[tokio::test]
15328    async fn an_unauthorized_pin_edition_never_folds() {
15329        let (_tmp, _guard, _owner) = init_test_db();
15330        let relay = MemoryRelay::new();
15331        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
15332        let general = community.channels[0].id;
15333
15334        let rogue = Keys::generate();
15335        let group = control::ControlPlane::of(&community).write_group().unwrap();
15336        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
15337        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
15338        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
15339        relay.publish(&wrap, &community.relays).await.unwrap();
15340
15341        follow_control(&relay, &community).await.unwrap();
15342        let read = read_channel_pins(&community, &general).unwrap();
15343        assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
15344        assert!(read.pins.is_empty());
15345    }
15346
15347    /// The silent owner-side widening: a pre-pins Admin role (founding mask)
15348    /// gains PIN_MESSAGES as one edition of the same entity; idempotent after.
15349    #[tokio::test]
15350    async fn owner_silently_widens_a_legacy_admin_role() {
15351        let (_tmp, _guard, owner) = init_test_db();
15352        let relay = MemoryRelay::new();
15353        let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
15354        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15355        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
15356
15357        // An Admin role exactly as a pre-pins build published it.
15358        let legacy = Role {
15359            role_id: rid.clone(),
15360            name: "Admin".into(),
15361            position: 1,
15362            permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
15363            scope: RoleScope::Server,
15364            color: 0,
15365        };
15366        publish_role(&relay, &community, &owner, &legacy, 1).await;
15367        follow_control(&relay, &community).await.unwrap();
15368
15369        assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
15370        follow_control(&relay, &community).await.unwrap();
15371        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
15372        let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
15373        assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
15374        assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
15375
15376        // Second call: nothing left to widen.
15377        assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
15378    }
15379
15380    /// §7 deletion duty: the author-curator's own deleted message leaves the
15381    /// list as an immediate omitting edition.
15382    #[tokio::test]
15383    async fn the_deletion_duty_omits_a_pinned_message() {
15384        let (_tmp, _guard, _owner) = init_test_db();
15385        let relay = MemoryRelay::new();
15386        let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
15387        let general = community.channels[0].id;
15388        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15389
15390        let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
15391        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15392        let opened = page
15393            .iter()
15394            .find_map(|f| match &f.event {
15395                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
15396                _ => None,
15397            })
15398            .unwrap();
15399        let ch = community.channels[0].clone();
15400        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
15401        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15402        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
15403        assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
15404
15405        // The author holds the bit (owner) → the duty publishes the omission at once.
15406        run_pin_duty(&relay, &ch_hex, &rumor_id, None).await.unwrap();
15407        let after = read_channel_pins(&community, &general).unwrap();
15408        assert!(after.pins.is_empty(), "the omitting edition landed");
15409        assert!(after.version >= 2, "a NEW edition, not a local erase");
15410    }
15411
15412    /// §7 edit duty: an edited pinned message gets its proof bundle refreshed,
15413    /// so keyless readers see the revision — and the duty is idempotent.
15414    #[tokio::test]
15415    async fn the_edit_duty_refreshes_a_pinned_proof() {
15416        let (_tmp, _guard, _owner) = init_test_db();
15417        let relay = MemoryRelay::new();
15418        let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
15419        let general = community.channels[0].id;
15420        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
15421
15422        let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
15423        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15424        let opened = page
15425            .iter()
15426            .find_map(|f| match &f.event {
15427                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
15428                _ => None,
15429            })
15430            .unwrap();
15431        let ch = community.channels[0].clone();
15432        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
15433        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15434        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
15435
15436        send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
15437        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15438        let edit_opened = page
15439            .iter()
15440            .find_map(|f| match &f.event {
15441                ChatEvent::Edit { opened, .. } => Some(opened.clone()),
15442                _ => None,
15443            })
15444            .expect("the edit reads back");
15445
15446        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone())).await.unwrap();
15447        let after = read_channel_pins(&community, &general).unwrap();
15448        assert_eq!(after.pins.len(), 1);
15449        assert_eq!(
15450            after.pins[0].content, "second thoughts",
15451            "the refreshed bundle proves the revision"
15452        );
15453        let v = after.version;
15454
15455        // Same revision again → monotonic guard, no new edition.
15456        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened)).await.unwrap();
15457        assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
15458    }
15459
15460    /// §7 author-side trigger: an own edit's relay echo is a dedup'd duplicate
15461    /// at ingest, so publish_chat's SEND echo must decide the duty itself —
15462    /// otherwise the author's own pin lags behind their message while every
15463    /// other client's follows ("the affected author acts at once").
15464    #[tokio::test]
15465    async fn an_own_edit_echo_carries_the_pin_duty_trigger() {
15466        let (_tmp, _guard, _owner) = init_test_db();
15467        let relay = MemoryRelay::new();
15468        let community = create_community(&relay, "OwnEdit", vec!["wss://r".into()], None).await.unwrap();
15469        let general = community.channels[0].id;
15470
15471        let rumor_id = send_message(&relay, &community, &general, "before").await.unwrap();
15472        send_edit(&relay, &community, &general, &rumor_id, "after").await.unwrap();
15473        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
15474        let edit_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Edit { .. })).expect("the edit reads back").event;
15475        let msg_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Message { .. })).expect("the message reads back").event;
15476
15477        let updated = crate::community::v2::inbound::ChatPersist::Updated {
15478            message: crate::types::Message::default(),
15479            edit_event: None,
15480        };
15481        let (target_hex, opened) = own_echo_pin_duty(&updated, edit_event).expect("an applied own edit fires the duty");
15482        assert_eq!(target_hex, rumor_id, "the duty targets the EDITED message, not the edit rumor");
15483        assert_ne!(opened.rumor_id.to_hex(), rumor_id, "and carries the edit's own opened stream for the bundle");
15484
15485        // A fresh message echo is not a duty…
15486        let new = crate::community::v2::inbound::ChatPersist::New(crate::types::Message::default());
15487        assert!(own_echo_pin_duty(&new, msg_event).is_none());
15488        // …and neither is a reaction-shaped Updated on a non-edit event.
15489        assert!(own_echo_pin_duty(&updated, msg_event).is_none());
15490    }
15491
15492    /// §7 Rotator duty: a private-channel rotation republishes the Pin List
15493    /// sealed under the NEW epoch — a member who joins after the rotation
15494    /// (holding only the new key) must not read the channel's pins as dark.
15495    #[tokio::test]
15496    async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
15497        let (_tmp, _guard, _owner) = init_test_db();
15498        let relay = MemoryRelay::new();
15499        let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
15500        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15501        let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
15502        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15503        let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
15504
15505        let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
15506        let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
15507        let opened = page
15508            .iter()
15509            .find_map(|f| match &f.event {
15510                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
15511                _ => None,
15512            })
15513            .unwrap();
15514        let ch = community.channel(&chan).unwrap().clone();
15515        let old_epoch = ch.epoch.0;
15516        let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
15517        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
15518        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
15519        let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
15520
15521        // Rotate the channel away from a (never-granted) member.
15522        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15523        rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &[Keys::generate().public_key()])
15524            .await
15525            .unwrap();
15526
15527        // The stored head is a NEW edition, sealed under the NEW epoch.
15528        let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
15529        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
15530        assert_eq!(
15531            parsed["epoch"].as_str().unwrap(),
15532            (old_epoch + 1).to_string(),
15533            "the reseal names the rotated epoch"
15534        );
15535        assert!(version > v_before, "a real edition, not a local rewrite");
15536
15537        // The rotator's own post-rotation view still verifies the pin.
15538        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15539        let read = read_channel_pins(&community, &chan).unwrap();
15540        assert!(!read.sealed);
15541        assert_eq!(read.pins.len(), 1);
15542        assert_eq!(read.pins[0].content, "sealed wisdom");
15543    }
15544
15545    /// The production ban-eraser: set_banlist must ECHO its published list
15546    /// into the local cache immediately. Before this, the cache moved only on
15547    /// a successful control fold — and a composing caller (ban = banlist →
15548    /// grant strip → refound) whose refound tripped re-read the stale list,
15549    /// so each of 19 real bans erased its predecessors.
15550    #[tokio::test]
15551    async fn a_published_banlist_echoes_locally_before_any_fold() {
15552        let (_tmp, _guard, _owner) = init_test_db();
15553        let relay = MemoryRelay::new();
15554        let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
15555        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15556        let spammer_a = "aa".repeat(32);
15557        let spammer_b = "bb".repeat(32);
15558
15559        // Ban A. NO fold runs — the cache must hold the publish regardless.
15560        set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
15561        assert_eq!(
15562            crate::db::community::get_community_banlist(&cid_hex).unwrap(),
15563            vec![spammer_a.clone()],
15564            "the publish echoes without waiting for a fold"
15565        );
15566
15567        // Ban B composes from the cache, exactly as the SDK does.
15568        let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15569        list.push(spammer_b.clone());
15570        set_banlist(&relay, &community, &list).await.unwrap();
15571        let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15572        assert!(
15573            held.contains(&spammer_a) && held.contains(&spammer_b),
15574            "sequential bans UNION; the second must not erase the first: {held:?}"
15575        );
15576
15577        // The wire agrees: a real fold confirms rather than regresses.
15578        follow_control(&relay, &community).await.unwrap();
15579        let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15580        assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
15581    }
15582}