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.events {
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                epoch: Some(community.root_epoch.0),
1031                extra: Default::default(),
1032            });
1033        }
1034        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1035        transport.publish(&event, &community.relays).await?;
1036        let signers = live_signers_for(&list, &cid_hex, now_ms());
1037        publish_invite_registry(transport, community, &session, &signers).await
1038    })
1039    .await
1040}
1041
1042/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
1043/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
1044/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
1045/// LAST live link empties the Registry → the community reads Private (a Refounding is
1046/// the owner's separate read-cut).
1047pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
1048    crate::db::scoped(async move {
1049        let session = crate::db::current_session();
1050        let signer = crate::signer::active_signer()?;
1051        let my_pk = me_pk()?;
1052        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1053        let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1054        let entry = list.entries.iter().find(|e| e.token == token_hex && e.community_id == cid_hex).cloned();
1055        let Some(entry) = entry else {
1056            // The list is OURS and was positively fetched; a token it neither vends is a
1057            // husk — a local row kept past a revocation or privatize performed elsewhere
1058            // (another device, Armada). The row carries no signer_sk, so no revocation
1059            // can EVER be built from it: erroring here just leaves an entry the owner can
1060            // neither use nor remove. Pruning the local record is the only completion.
1061            // (Ok(None) above still errors — an unreadable list is not proof of absence.)
1062            let tombstoned = list.tombstones.iter().any(|t| t.token == token_hex && t.community_id == cid_hex);
1063            crate::log_warn!(
1064                "[Invites] link {} is not in the fetched invite list ({}) — pruning the local record",
1065                &token_hex[..token_hex.len().min(8)],
1066                if tombstoned { "already tombstoned" } else { "retired elsewhere" }
1067            );
1068            let _ = crate::db::community::delete_public_invite(token_hex);
1069            return Ok(());
1070        };
1071        // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
1072        let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1073        let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1074        transport.publish_durable(&revocation, &community.relays).await?;
1075        // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
1076        list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1077        list.entries.retain(|e| e.token != token_hex);
1078        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1079        transport.publish(&event, &community.relays).await?;
1080        let signers = live_signers_for(&list, &cid_hex, now_ms());
1081        // Does anyone ELSE still vend a live link? Folded BEFORE our own registry
1082        // republish: a fold taken after it would race relay propagation and read our
1083        // own stale (pre-revoke) registry, so the privatize decision would flip to
1084        // "still public" almost every time. Links are per-creator (CORD-05 §5), so
1085        // only the other creators' sets matter here — ours is `signers`.
1086        // (v1 parity: `revoke_public_invite`'s B1 fix refreshes the aggregate first
1087        // for exactly this reason.)
1088        let others_vend = if signers.is_empty() {
1089            match fetch_control_plane_whole(transport, community).await {
1090                Some(editions) => {
1091                    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1092                        .unwrap_or_default()
1093                        .into_iter()
1094                        .filter(|(_, f)| f.0 == community.root_epoch.0)
1095                        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1096                        .collect();
1097                    let owner_hex = community.owner().map(|o| o.to_hex()).unwrap_or_default();
1098                    let authority = fold_authority(community, &editions, &floors);
1099                    live_invite_link_sets(community.id(), &owner_hex, &editions, &authority, &floors)
1100                        .iter()
1101                        .any(|s| s.creator_hex != my_pk.to_hex())
1102                }
1103                // Unreadable plane: assume someone else still vends. Rotating on a
1104                // guess would burn an epoch (and strand pre-split clients) over a
1105                // transport blip; the community simply stays public until a read
1106                // that succeeds says otherwise.
1107                None => true,
1108            }
1109        } else {
1110            false
1111        };
1112        publish_invite_registry(transport, community, &session, &signers).await?;
1113        let _ = crate::db::community::delete_public_invite(token_hex);
1114        // CORD-06 §3: converting a Public Community to Private is a Refounding
1115        // trigger. Revoking only stops NEW acquisitions — everyone who already
1116        // fetched the bundle keeps the `community_root` until it rolls, so without
1117        // this the community reads Private while every past link-holder retains
1118        // read access forever. (v1 does this in `revoke_public_invite`; v2 shipped
1119        // without it.)
1120        //
1121        // Best-effort and retried, NEVER the revocation's verdict: the tombstone has
1122        // already landed, and reporting failure here would read as "the link is
1123        // still live" — the opposite of the truth — while a retry would fail anyway
1124        // (its list entry is gone). A Refounding needs BAN, so a link creator who
1125        // holds only CREATE_INVITE legitimately can't rotate; that surfaces as a
1126        // logged warning, not a broken revoke.
1127        if signers.is_empty() && !others_vend {
1128            let mut rotated = false;
1129            for attempt in 0..3u8 {
1130                match refound_community(transport, community, &[]).await {
1131                    Ok(_) => {
1132                        rotated = true;
1133                        break;
1134                    }
1135                    Err(e) if attempt == 2 => {
1136                        crate::log_warn!(
1137                            "[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",
1138                            &cid_hex[..8.min(cid_hex.len())]
1139                        );
1140                        crate::emit_event(
1141                            "community_privatize_rotation_failed",
1142                            &serde_json::json!({ "community_id": cid_hex, "error": e }),
1143                        );
1144                    }
1145                    Err(_) => continue,
1146                }
1147            }
1148            if rotated {
1149                crate::log_info!("[v2] {} privatized: base key rotated so link-joined readers are cut off", &cid_hex[..8.min(cid_hex.len())]);
1150            }
1151        }
1152        Ok(())
1153    })
1154    .await
1155}
1156
1157// ── Severing containment (CORD-06 Severing Refounding) ──────────────────────
1158
1159/// Persisted sever marks for a community: `root_epoch -> rotation at_secs`.
1160/// A mark means "epoch E was a containment": link severance retries against it
1161/// on every follow pass, and a rescue extension of that hop re-stamps the tag.
1162pub(crate) fn sever_marks(cid_hex: &str) -> std::collections::BTreeMap<u64, u64> {
1163    crate::db::settings::get_sql_setting(format!("v2_sever:{cid_hex}"))
1164        .ok()
1165        .flatten()
1166        .and_then(|v| serde_json::from_str(&v).ok())
1167        .unwrap_or_default()
1168}
1169
1170/// Who minted each epoch: `root_epoch -> rotator hex`. CORD-02 §5 honors a
1171/// Guestbook snapshot "only from the npub whose Refounding minted that epoch",
1172/// so the fold has to remember which npub that was — the owner is only the
1173/// answer when the owner did the refounding.
1174pub(crate) fn epoch_minters(cid_hex: &str) -> std::collections::BTreeMap<u64, String> {
1175    crate::db::settings::get_sql_setting(format!("v2_minter:{cid_hex}"))
1176        .ok()
1177        .flatten()
1178        .and_then(|v| serde_json::from_str(&v).ok())
1179        .unwrap_or_default()
1180}
1181
1182/// Remember the rotator that minted `epoch`. Recorded at ADOPTION (and by the
1183/// refounder for its own mint), so it is always an npub this device verified as
1184/// an authorized rotator — never one a snapshot claims for itself.
1185pub(crate) fn record_epoch_minter(cid_hex: &str, epoch: u64, rotator_hex: &str) {
1186    let mut m = epoch_minters(cid_hex);
1187    if m.get(&epoch).is_some_and(|held| held == rotator_hex) {
1188        return;
1189    }
1190    m.insert(epoch, rotator_hex.to_string());
1191    if let Ok(v) = serde_json::to_string(&m) {
1192        let _ = crate::db::settings::set_sql_setting(format!("v2_minter:{cid_hex}"), v);
1193    }
1194}
1195
1196/// Record a severed rotation. Idempotent; marks only accumulate — the row is a
1197/// few bytes and clearing one would let a failed severance read as done (B4).
1198pub(crate) fn add_sever_mark(cid_hex: &str, epoch: u64, at_secs: u64) {
1199    let mut marks = sever_marks(cid_hex);
1200    marks.insert(epoch, at_secs);
1201    if let Ok(v) = serde_json::to_string(&marks) {
1202        let _ = crate::db::settings::set_sql_setting(format!("v2_sever:{cid_hex}"), v);
1203    }
1204}
1205
1206/// Sever MY OWN condemned public links: revoke (not refresh) every live link of
1207/// mine for this community whose minted-at epoch sits below the highest severed
1208/// epoch. The epoch is the discriminator — deterministic on every device, so a
1209/// second device adopting the containment late can never eat the fresh link the
1210/// owner minted to re-open (it carries the post-raid epoch), while a link minted
1211/// mid-raid by a not-yet-adopted device carries the raid-era epoch and dies.
1212/// Batched: every tombstone publishes, then ONE 13303 republish and ONE registry
1213/// edition for the whole set. Deliberately WITHOUT `revoke_public_link`'s
1214/// last-link refound escalation: every caller here sits inside a rotation
1215/// (refound or follow), where that escalation self-deadlocks on `follow_lock` —
1216/// and the severed rotation IS the read-cut the escalation exists to provide.
1217/// Returns the number revoked; `Ok(0)` when there is nothing of mine to sever.
1218async fn sever_own_links<T: Transport + ?Sized>(
1219    transport: &T,
1220    community: &CommunityV2,
1221) -> Result<usize, String> {
1222    crate::db::scoped(async move {
1223        let session = crate::db::current_session();
1224        let signer = crate::signer::active_signer()?;
1225        let my_pk = me_pk()?;
1226        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1227        let Some(condemn_below) = sever_marks(&cid_hex).keys().copied().max() else {
1228            return Ok(0); // no containment on record — nothing is condemned.
1229        };
1230        // Local fast-path: the folded Registry names every creator with a live
1231        // link, and my own mints also leave local rows. Neither names me → no
1232        // 13303 fetch; a plain member pays nothing here.
1233        let maybe_mine = crate::db::community::invite_link_set_creators(&cid_hex)
1234            .map(|cs| cs.contains(&my_pk.to_hex()))
1235            .unwrap_or(true)
1236            || !crate::db::community::list_public_invites(&cid_hex).unwrap_or_default().is_empty();
1237        if !maybe_mine {
1238            return Ok(0);
1239        }
1240        let Some(mut list) = fetch_invite_list(transport, &community.relays).await? else {
1241            return Ok(0); // no list ever published — nothing of mine exists.
1242        };
1243        let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1244        let victims: Vec<invite::InviteEntry> = list
1245            .entries
1246            .iter()
1247            .filter(|e| e.community_id == cid_hex && !dead.contains(e.token.as_str()) && e.epoch.unwrap_or(0) < condemn_below)
1248            .cloned()
1249            .collect();
1250        if victims.is_empty() {
1251            return Ok(0);
1252        }
1253        let mut revoked_tokens: Vec<String> = Vec::with_capacity(victims.len());
1254        for entry in &victims {
1255            // One corrupt entry must not wedge the batch (and with it the
1256            // community's Public/Private reading) forever: tombstone the husk —
1257            // no revocation can ever be built from it anyway.
1258            match Keys::parse(&entry.signer_sk) {
1259                Ok(link_signer) => {
1260                    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1261                    transport.publish_durable(&revocation, &community.relays).await?;
1262                }
1263                Err(_) => crate::log_warn!("[Sever] link {} carries a malformed signer — tombstoning the husk", &entry.token[..entry.token.len().min(8)]),
1264            }
1265            list.tombstones.push(invite::InviteTombstone {
1266                token: entry.token.clone(),
1267                community_id: cid_hex.clone(),
1268                extra: Default::default(),
1269            });
1270            revoked_tokens.push(entry.token.clone());
1271        }
1272        list.entries.retain(|e| !revoked_tokens.contains(&e.token));
1273        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1274        transport.publish(&event, &community.relays).await?;
1275        let signers = live_signers_for(&list, &cid_hex, now_ms());
1276        publish_invite_registry(transport, community, &session, &signers).await?;
1277        for token in &revoked_tokens {
1278            let _ = crate::db::community::delete_public_invite(token);
1279        }
1280        crate::log_info!("[Sever] revoked {} own public link(s) (containment)", revoked_tokens.len());
1281        Ok(revoked_tokens.len())
1282    })
1283    .await
1284}
1285
1286/// The follow-pass severance retry (B4): while a sever mark exists and I still
1287/// hold live links at-or-below its line, re-attempt. A miss here is load-bearing
1288/// — a stale registry keeps the community reading Public, which silently skips
1289/// every future ban's refound — so this runs on every follow until it drains.
1290pub(crate) async fn retry_pending_severance<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
1291    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1292    if sever_marks(&cid_hex).is_empty() {
1293        return;
1294    }
1295    if let Err(e) = sever_own_links(transport, community).await {
1296        crate::log_warn!("[Sever] pending link severance retry failed (will retry next follow): {e}");
1297    }
1298}
1299
1300/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
1301/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
1302/// coordinate with the CURRENT community state, so a link shared once keeps working
1303/// across rotations. Best-effort.
1304pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1305    crate::db::scoped(async move {
1306        let session = crate::db::current_session();
1307        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1308        // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
1309        // Err — the caller (a post-refounding refresh) must be able to retry, or live
1310        // links keep serving the PRE-refound root and new joiners land on the dead
1311        // epoch. A genuinely-empty list is Ok (nothing to refresh).
1312        let signer = crate::signer::active_signer()?;
1313        let my_pk = me_pk()?;
1314        let query = Query {
1315            kinds: vec![super::kind::INVITE_LIST],
1316            authors: vec![my_pk.to_hex()],
1317            limit: Some(4),
1318            ..Default::default()
1319        };
1320        let events = transport.fetch(&query, &community.relays).await?;
1321        let mut best: Option<(u64, invite::InviteList)> = None;
1322        for e in events {
1323            if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1324                let at = e.created_at.as_secs();
1325                if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1326                    best = Some((at, l));
1327                }
1328            }
1329        }
1330        let Some((_, list)) = best else {
1331            return Ok(());
1332        };
1333        let creator = my_pk;
1334        let now = now_ms();
1335        let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1336        // A containment condemns every link below its epoch. Refreshing one would
1337        // RE-ARM the exact door the severing rotation closed (with the new root,
1338        // no less) — and once re-armed, no retry could tell it from a legitimate
1339        // link. Skip here; the severance retry owns their burial.
1340        let condemn_below = sever_marks(&cid_hex).keys().copied().max().unwrap_or(0);
1341        for entry in &list.entries {
1342            if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1343                continue;
1344            }
1345            if entry.epoch.unwrap_or(0) < condemn_below {
1346                continue;
1347            }
1348            // An expired link can't be joined, so refreshing it just re-states a
1349            // door that isn't there (CORD-05 §1/§5).
1350            if entry.expires_at.is_some_and(|exp| now > exp) {
1351                continue;
1352            }
1353            let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1354            let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1355            let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1356            let bundle_key = super::derive::invite_bundle_key(&token);
1357            if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1358                let _ = transport.publish_durable(&event, &community.relays).await;
1359            }
1360        }
1361        // Republish the Registry from the same pruned view. Expiry is the one way a
1362        // link dies with no user action, so without a heal point here the aggregate
1363        // never empties and the community reads Public long after its last door
1364        // shut (CORD-05 §5). Idempotent when nothing lapsed.
1365        //
1366        // Only for a creator who actually minted here: one Invite List spans every
1367        // community, so a member holding links ELSEWHERE would otherwise publish an
1368        // empty Registry edition into this one on every rotation they adopt — a
1369        // control-plane write, and a version bump, for a coordinate they never owned.
1370        let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1371        if !mine_here {
1372            return Ok(());
1373        }
1374        let signers = live_signers_for(&list, &cid_hex, now);
1375        let _ = publish_invite_registry(transport, community, &session, &signers).await;
1376        Ok(())
1377    })
1378    .await
1379}
1380
1381/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
1382/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
1383/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
1384/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
1385pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1386    let Ok(owner) = community.owner() else { return false };
1387    // Truncation fails toward Public: over-stating it only makes a caller take the
1388    // stronger remedy (privatise + re-found + reissue), while under-stating it
1389    // leaves a live link open behind a ban.
1390    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1391    let cid = community.id();
1392    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1393    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1394        .unwrap_or_default()
1395        .into_iter()
1396        .filter(|(_, f)| f.0 == community.root_epoch.0)
1397        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1398        .collect();
1399    let authority = fold_authority(community, &editions, &floors);
1400    !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1401}
1402
1403/// Page the WHOLE control plane, not the newest window: a registry pushed out of a
1404/// single page reads as retired, and any member can push it out since the plane key
1405/// comes from the community root they hold. `None` = it could NOT be read whole
1406/// (transport failure, same-second wall, pager depth), so a caller must not mistake
1407/// an empty fold for absence.
1408async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1409    let control = control::ControlPlane::of(community);
1410    let mut editions: Vec<ParsedEdition> = Vec::new();
1411    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1412    let mut oldest: Option<u64> = None;
1413    let mut until: Option<u64> = None;
1414    for page in 0..COMPACT_MAX_PAGES {
1415        // Full, not Quorum: `None` here means "could not be read whole", and a
1416        // majority that simply lacks another creator's Registry answers as fast as
1417        // one that has it — so a Quorum read returns Some(empty) and the plane reads
1418        // link-free. The ban path turns that into a Refounding of a PUBLIC community,
1419        // which CORD-05 §5 forbids: the link refresh re-posts the bundle behind the
1420        // same URL, so the rotation severs nothing and buries foreign-link joiners.
1421        let query = Query {
1422            kinds: vec![stream::KIND_WRAP],
1423            authors: vec![control.pk_hex()],
1424            until,
1425            limit: Some(FOLLOW_PAGE),
1426            evidence: crate::community::transport::Evidence::Full,
1427            ..Default::default()
1428        };
1429        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1430        let mut fresh = 0usize;
1431        for w in &wraps {
1432            if !seen_wraps.insert(w.id) {
1433                continue;
1434            }
1435            fresh += 1;
1436            let at = w.created_at.as_secs();
1437            if oldest.is_none_or(|o| at < o) {
1438                oldest = Some(at);
1439            }
1440            if let Ok((ed, _)) = control.open(w) {
1441                editions.push(ed);
1442            }
1443        }
1444        if fresh == 0 {
1445            if wraps.len() >= FOLLOW_PAGE {
1446                return None; // same-second wall: the plane can't be read whole
1447            }
1448            return Some(editions);
1449        }
1450        until = oldest;
1451        if page + 1 == COMPACT_MAX_PAGES {
1452            return None;
1453        }
1454    }
1455    Some(editions)
1456}
1457
1458/// The live link coordinates PER AUTHORISED CREATOR across every Registry (vsk-8);
1459/// non-empty ⇒ the Community is Public, and the per-creator split is what drives
1460/// "X has N active invite links". Pure over an already-fetched edition set so the
1461/// on-demand probe and the control follow fold it identically.
1462fn live_invite_link_sets(
1463    cid: &crate::community::CommunityId,
1464    owner_hex: &str,
1465    editions: &[ParsedEdition],
1466    authority: &AuthoritySet,
1467    floors: &Floors,
1468) -> Vec<crate::db::community::InviteLinkSetRow> {
1469    use crate::community::roles::Permissions;
1470    use std::collections::BTreeMap;
1471    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1472    for e in editions {
1473        if e.vsk == vsk::INVITE_LINKS {
1474            by_eid.entry(e.entity_id).or_default().push(e);
1475        }
1476    }
1477    let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1478    for (eid, group) in &by_eid {
1479        // Authority BEFORE the fold, matching `apply_control_fold`. `fold_head`
1480        // picks an equal-version winner author-blind (lowest inner id, which an
1481        // author can grind), so folding first would let any member occupy the head
1482        // slot and have the whole registry dropped by the check below — silently
1483        // retiring a live invite link, i.e. flipping the community to Private.
1484        let authed: Vec<&ParsedEdition> = group
1485            .iter()
1486            .copied()
1487            .filter(|p| {
1488                let author = p.author.to_hex();
1489                // The creator must hold CREATE_INVITE, not be banned, AND own this coordinate.
1490                !authority.banned.contains(&author)
1491                    && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1492                    && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1493            })
1494            .collect();
1495        if authed.is_empty() {
1496            continue;
1497        }
1498        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1499        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1500        if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1501            if signers.is_empty() {
1502                continue; // a creator who retired every link is absent, not a zero row
1503            }
1504            sets.push(crate::db::community::InviteLinkSetRow {
1505                creator_hex: authed[hi].author.to_hex(),
1506                locators: signers.iter().map(|p| p.to_hex()).collect(),
1507            });
1508        }
1509    }
1510    sets
1511}
1512
1513/// Flatten per-creator sets into the aggregate the `invite_registry` column holds.
1514fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1515    let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1516    flat.sort();
1517    flat.dedup();
1518    flat
1519}
1520
1521/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
1522/// delivered community_root is genuinely the owner's, persist the community, and
1523/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
1524/// paths. Takes the caller's `std::sync::Arc<crate::db::Session>` (captured BEFORE any network fetch the
1525/// caller did) so the whole operation stays with one account across that I/O.
1526async fn accept_bundle<T: Transport + ?Sized>(
1527    transport: &T,
1528    bundle: &CommunityInvite,
1529    invited_by: Option<PublicKey>,
1530    announce_join: bool,
1531) -> Result<CommunityV2, String> {
1532    crate::db::scoped(async move {
1533        let signer = crate::signer::active_signer()?;
1534        let my_pk = me_pk()?;
1535        let at_ms = now_ms();
1536        // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
1537        if bundle.expired(at_ms) {
1538            return Err("this invite has expired".to_string());
1539        }
1540        // `from_bundle` re-validates bounds + the owner commitment fail-closed.
1541        let community = CommunityV2::from_bundle(bundle, at_ms)?;
1542        // Captured before the save below: a re-accept of a held community must not
1543        // re-announce a membership this account already declared.
1544        let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1545
1546        // Authenticate the delivered community_root before trusting it. The owner
1547        // commitment proves WHO the owner is, but community_root (and channel keys) are
1548        // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
1549        // with an attacker-chosen root and silently partition the joiner onto planes
1550        // only the attacker controls. Requiring the owner's genesis to open under the
1551        // delivered root closes that eclipse; also reconciles channel classification.
1552        // A preview verified the SAME (id, root) moments ago → reuse its fold instead
1553        // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
1554        let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1555            v.session.is_live()
1556                && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1557                && v.community_id == community.id().0
1558                && v.community_root == community.community_root
1559        });
1560        let (community, join_heads, join_banlist, join_pins, join_banlist_content, join_roles, join_authority_heads) =
1561            match handoff {
1562                Some(v) => {
1563                    let mut c = v.folded;
1564                    // The preview holds no acquisition time — stamp the JOIN's.
1565                    c.created_at_ms = at_ms;
1566                    (c, v.heads, v.banned, v.pins, v.banlist_persist, v.roles, v.authority_heads)
1567                }
1568                None => {
1569                    let vj = verify_owner_root_and_reconcile(transport, community).await?;
1570                    (vj.community, vj.heads, vj.banned, vj.pins, vj.banlist_persist, vj.roles, vj.authority_heads)
1571                }
1572            };
1573
1574        // A dissolved community is a grave (CORD-02 §9): refuse to join it.
1575        if is_dissolved(transport, &community).await {
1576            return Err(ERR_DISSOLVED.to_string());
1577        }
1578
1579        // Join-time ban gate (CORD-04 §4, Armada parity): an honest client refuses to join a
1580        // community whose authorized banlist names it — before the Guestbook Join publishes
1581        // and before any local write. Every door funnels through here (direct invite, parked,
1582        // public link, migration), so none of them needs its own exclusion.
1583        if join_banlist.contains(&my_pk.to_hex()) {
1584            return Err("you are banned from this community".to_string());
1585        }
1586
1587        // The account must not have swapped since the guard was captured (which was
1588        // before any fetch the caller / the verify above performed) — else we'd write
1589        // A's join into B.
1590        // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
1591        // community row lands (floors-then-state, so a mid-seed error can't leave saved
1592        // state outrunning its floor); the first post-join follow then can't persist a
1593        // state below what this join already showed.
1594        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1595        for h in join_heads.iter().chain(join_authority_heads.iter()) {
1596            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)?;
1597        }
1598        crate::db::community::save_community_v2(&community)?;
1599        // Archive the joined root at its epoch, so this member reads Public-channel
1600        // history from their join epoch onward across later Refoundings (CORD-03 §3).
1601        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1602        // Same for each granted Private-channel key: the archive is what lets its
1603        // history stay readable after the channel rotates away from this key.
1604        for ch in &community.channels {
1605            if let (true, Some(key)) = (ch.private, ch.key) {
1606                let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1607            }
1608        }
1609        // Persist what the verification walk already folded: the control plane WAS
1610        // read, so making the fresh member wait for the follow worker's queued
1611        // re-walk to see pins or enforce bans is pure lag (it read 20-30s on a busy
1612        // queue). The system "modified the Pins" line still arrives via that first
1613        // follow — a join backfills state, it doesn't announce edits.
1614        if let Some((banned_list, version)) = &join_banlist_content {
1615            let _ = crate::db::community::set_community_banlist(&cid_hex, banned_list, *version as i64);
1616        }
1617        // The ROSTER, same rule as the banlist and pins above: the walk already
1618        // paid for it. Persisted BEFORE the channels register, because the first
1619        // history ingest reads it to honor moderation hides — an empty roster
1620        // authorizes nobody, so every admin-hidden message would render to the
1621        // fresh member until the follow worker's queued re-walk landed.
1622        if let Some((roles, at)) = &join_roles {
1623            let _ = crate::db::community::set_community_roles(&cid_hex, roles, *at);
1624        }
1625        for (channel_hex, content, version, _author, _at) in &join_pins {
1626            if matches!(
1627                crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64),
1628                Ok(true)
1629            ) {
1630                crate::emit_event(
1631                    "community_pins_updated",
1632                    &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
1633                );
1634            }
1635        }
1636
1637        // Announce our Guestbook Join, echoing the invite attribution when present.
1638        // Only an ACTUAL join speaks: a re-accept of a held community, or a
1639        // cross-device key sync (announce_join=false), is not a membership event —
1640        // the account's original Join already stands in the guestbook, and every
1641        // re-publish renders as "<user> has joined" spam for the whole community.
1642        if announce_join && !already_held {
1643            let attribution = invited_by
1644                .map(|p| p.to_hex())
1645                .or_else(|| bundle.creator_npub.clone())
1646                .zip(Some(bundle.label.clone().unwrap_or_default()));
1647            let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1648            let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1649            let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1650            if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1651                let _ = transport.publish(&join_wrap, &community.relays).await;
1652            }
1653        }
1654
1655        // Record the membership across devices (CORD-02 §8). The inline attempt covers the
1656        // happy path; anything else hands off to the durable retry, because an unrecorded
1657        // join is what strands a community behind a stale tombstone.
1658        //
1659        // Only for a REAL join. `announce_join == false` is the list-sync adoption path,
1660        // and those entries were just read FROM this list — republishing it re-records what
1661        // it already holds. A device adopting N entries would otherwise rebuild and publish
1662        // the whole document N times, concurrently, every copy identical, on the boot path
1663        // that is already the slowest thing the app does.
1664        if announce_join {
1665            match republish_community_list(transport, Some(community.id())).await {
1666                Ok(true) => {}
1667                Ok(false) => republish_community_list_durable(Some(*community.id())),
1668                Err(e) => {
1669                    crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1670                    republish_community_list_durable(Some(*community.id()));
1671                }
1672            }
1673        }
1674        Ok(community)
1675    })
1676    .await
1677}
1678
1679/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
1680/// channel classification from the owner's editions. `community_id` commits only
1681/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
1682/// every synced Community List) — so a forged invite can present a real community's
1683/// id/owner/salt with an attacker-chosen root; every plane then derives from that
1684/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
1685/// owner commitment still "verifies". The defense: the owner's genesis metadata
1686/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
1687/// attacker can't forge the owner's seal — so its presence on the control plane
1688/// derived from the delivered root proves that root. On a ROTATED plane (epoch > 0)
1689/// the compaction may have carried an admin-signed metadata head instead (CORD-06
1690/// re-wraps heads with their original signatures), so the anchor there is the
1691/// community-bound metadata head plus any owner-signed edition under the same root.
1692/// Fail-closed: no anchor (forged invite, or relays unreachable) → refuse to join.
1693/// On success, folds the owner's authoritative editions to heal a bundle that
1694/// misclassified a channel.
1695/// Everything the join-time verification walk folded, handed to the accept path so
1696/// what the join VERIFIED is also what it PERSISTS. The walk already paid for the
1697/// control plane read; deferring persistence to the first post-join follow re-pays
1698/// the network cost and leaves the fresh member pins-blind and banlist-blind for
1699/// the whole follow-queue delay.
1700struct VerifiedJoin {
1701    community: CommunityV2,
1702    heads: Vec<FoldedHead>,
1703    /// The join-gate banlist (authorized fold over the any-author edition set).
1704    banned: std::collections::BTreeSet<String>,
1705    /// Pin List heads folded under the FULL join-time authority (curators included,
1706    /// same §5 gate as any later follow): `(channel_hex, content, version, author_npub, created_at)`.
1707    pins: Vec<(String, String, u64, String, u64)>,
1708    /// The authorized banlist head's content + version, for local persistence.
1709    banlist_persist: Option<(Vec<String>, u64)>,
1710    /// The FULL authorized roster (admins included) from the same walk, with the
1711    /// newest roster edition's `created_at`. `None` when the fold saw a gap.
1712    ///
1713    /// This is the one authority artifact the join used to throw away — and a
1714    /// fresh member whose roster is empty until the follow worker's re-walk
1715    /// renders every moderator-hidden message in the meantime: the hide gate
1716    /// reads the stored roster, and an empty roster authorizes nobody.
1717    roles: Option<(crate::community::roles::CommunityRoles, i64)>,
1718    /// Role/Grant/Banlist heads folded under the full authority, so an
1719    /// ADMIN-signed grant seeds the same refuse-downgrade floor an owner-signed
1720    /// one does.
1721    authority_heads: Vec<FoldedHead>,
1722}
1723
1724async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1725    transport: &T,
1726    community: CommunityV2,
1727) -> Result<VerifiedJoin, String> {
1728    let owner = community.owner()?;
1729    let control = control::ControlPlane::of(&community);
1730    let control_pk = control.pk_hex();
1731
1732    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
1733    // wraps ONLY to a connection authenticated AS the stream key — Concord's
1734    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
1735    // satisfy the gate and the control plane reads back empty. Register this
1736    // community's stream keys + start the challenge responder so the fetch below
1737    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
1738    super::streamauth::prime(&community);
1739
1740    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
1741    // community_id`) at the root-derived control plane. The genesis eid pins it to
1742    // THIS community, and it lives ONLY under the real root — so a forged root can't
1743    // produce one: an edition's seal carries no community binding, but another
1744    // community's genesis has a different eid, and this community's own genesis is
1745    // unreadable without its real root (which the forger lacks). ("Any owner edition"
1746    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
1747    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
1748    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
1749    // binding the root into community_id (protocol, deferred).
1750    //
1751    // Seed `until` with a FAR-FUTURE constant (NOT now-based), and request
1752    // Evidence::Full EXPLICITLY below: this walk draws an ABSENCE verdict (no
1753    // owner-signed genesis ⇒ reject), which trusts only the completest union —
1754    // an open partial window misses a genesis on a lagging relay (routine over
1755    // Tor). A constant beyond any real created_at clips NOTHING — so neither
1756    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
1757    // now-based bound could clip either). Break on an EMPTY page (a short page is a
1758    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
1759    // that buries the genesis past the walk is the deferred protocol residual.
1760    const PAGE: usize = 500;
1761    const MAX_PAGES: usize = 4;
1762    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
1763    let mut editions: Vec<ParsedEdition> = Vec::new();
1764    let mut all_editions: Vec<ParsedEdition> = Vec::new();
1765    let mut found_genesis = false;
1766    // Rotated planes (CORD-06): compaction re-wraps each entity's CURRENT head with
1767    // its ORIGINAL signature, so if an admin last edited the metadata the plane holds
1768    // no owner-signed vsk-0 at all — the strict genesis anchor is unsatisfiable there.
1769    // Fallback pair for epoch > 0: the community-bound metadata head (any signer) PLUS
1770    // at least one owner-signed edition opened under this root. A non-member forger
1771    // can produce neither; the sibling-community rewrap residual this reopens is the
1772    // same class the spec defers to root-in-id binding.
1773    let mut compacted_metadata = false;
1774    crate::log_debug!(
1775        "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1776        &control_pk[..12], community.root_epoch, community.relays
1777    );
1778    let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1779        found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1780    };
1781    for attempt in 0..2 {
1782        editions.clear();
1783        all_editions.clear();
1784        compacted_metadata = false;
1785        let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1786        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1787        for page_no in 0..MAX_PAGES {
1788            let query = Query {
1789                kinds: vec![stream::KIND_WRAP],
1790                authors: vec![control_pk.clone()],
1791                until,
1792                limit: Some(PAGE),
1793                evidence: crate::community::transport::Evidence::Full,
1794                ..Default::default()
1795            };
1796            let wraps = transport.fetch(&query, &community.relays).await?;
1797            crate::log_trace!(
1798                "[JoinVerify] attempt {} page {}: fetched {} wraps",
1799                attempt, page_no, wraps.len()
1800            );
1801            // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1802            // siblings at a page boundary (and the genesis with them); re-served
1803            // boundary events are free, and no-new-events means exhausted.
1804            let mut oldest = u64::MAX;
1805            let mut fresh = 0usize;
1806            for w in &wraps {
1807                if !seen_wraps.insert(w.id) {
1808                    continue;
1809                }
1810                fresh += 1;
1811                oldest = oldest.min(w.created_at.as_secs());
1812                if let Ok((ed, _)) = control.open(w) {
1813                    crate::log_trace!(
1814                        "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1815                        ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1816                        ed.author == owner, w.created_at.as_secs()
1817                    );
1818                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1819                        if ed.author == owner {
1820                            found_genesis = true;
1821                        } else {
1822                            compacted_metadata = true;
1823                        }
1824                    }
1825                    if ed.author == owner {
1826                        editions.push(ed.clone());
1827                    }
1828                    // Any-author set for the join-time authority fold below: the banlist head
1829                    // may be admin-signed, and its authority chains to the owner regardless.
1830                    all_editions.push(ed);
1831                }
1832            }
1833            crate::log_debug!(
1834                "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1835                attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1836            );
1837            if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1838                break; // authenticated, or the relay is exhausted.
1839            }
1840            until = Some(oldest);
1841        }
1842        if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1843            break;
1844        }
1845        if attempt == 0 {
1846            // AUTH-gating relays: the first walk's REQ triggers the NIP-42 challenge,
1847            // but nostr-sdk's own retry re-auths as the USER key — which doesn't
1848            // satisfy a stream-authors gate — and can land before the responder's
1849            // stream-key auth settles, reading the plane back EMPTY. Replay the
1850            // remembered challenges for every registered stream key, then walk once
1851            // more on the settled connection.
1852            if let Some(client) = crate::state::nostr_client() {
1853                super::streamauth::prime_auth(&client, &community.relays).await;
1854            }
1855        }
1856    }
1857    if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1858        return Err(
1859            "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"
1860                .to_string(),
1861        );
1862    }
1863    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1864    // entity). The heads this fold verified are returned for the caller to SEED as
1865    // the initial floor once the community row is saved — without that, the first
1866    // post-join follow would bootstrap floor-less and could persist a state BELOW
1867    // what this join already verified and showed.
1868    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1869    // above), and the owner is supreme — so owner-only authority suffices. The full
1870    // roster (admins) folds on the first post-join follow_control.
1871    let empty_floors = Floors::new();
1872    let authority = AuthoritySet::owner_only();
1873    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1874    // Join-time banlist: fold authority over the ANY-author edition set (roles/grants
1875    // chain to the genesis-verified owner; the banlist head is honored only if its signer
1876    // held BAN). Returned so the accept path can refuse a banned self BEFORE it publishes
1877    // a Guestbook Join — the gate every join door shares (Armada parity, CORD-04 §4).
1878    let authority_full = fold_authority(&community, &all_editions, &empty_floors);
1879    // Pins under the FULL authority: a curator-authored Pin List is as valid at join
1880    // as on any later follow (same PIN_MESSAGES + citation gate, same owner-chained
1881    // roster). Only the pins are taken from this pass — the DOCUMENT adoption above
1882    // stays owner-only, its stricter envelope unchanged.
1883    let pins = apply_control_fold(&community, &all_editions, &empty_floors, &authority_full).pins_persist;
1884    // The newest roster edition's stamp, exactly as follow_control computes it, so
1885    // the follow's own freshness gate composes with a join-seeded roster.
1886    let newest_roster_at = all_editions
1887        .iter()
1888        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
1889        .map(|e| e.created_at as i64)
1890        .max()
1891        .unwrap_or(0);
1892    // An anchored walk read the plane back to genesis, so the window is complete
1893    // by construction; `gapped` still gates the persist for the same reason
1894    // follow_control's does — a withheld edition must not fold into a baseline.
1895    let roles = (!authority_full.gapped).then(|| (authority_full.roles.clone(), newest_roster_at));
1896    Ok(VerifiedJoin {
1897        community: fold.updated.unwrap_or(community),
1898        heads: fold.heads,
1899        banned: authority_full.banned,
1900        pins,
1901        banlist_persist: authority_full.banlist_persist,
1902        roles,
1903        authority_heads: authority_full.heads,
1904    })
1905}
1906
1907/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1908/// then run the shared accept path. The recipient's consent IS this call. No
1909/// network await precedes the accept, so the guard captured here suffices.
1910pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1911    let signer = crate::signer::active_signer()?;
1912    let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1913    accept_bundle(transport, &bundle, Some(inviter), true).await
1914}
1915
1916/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1917/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1918/// bundle validation, then runs the shared accept path (which re-verifies the owner
1919/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1920/// Join attribution.
1921pub async fn accept_parked_invite<T: Transport + ?Sized>(
1922    transport: &T,
1923    bundle_json: &str,
1924    inviter_hex: Option<&str>,
1925) -> Result<CommunityV2, String> {
1926    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1927    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1928    accept_bundle(transport, &bundle, invited_by, true).await
1929}
1930
1931/// Accept v2 JoinMaterial recovered from a v1→v2 migration dissolution payload (`m`). The
1932/// material IS a bundle's membership subset — rebuild the invite and run the SHARED accept
1933/// path, which re-verifies the owner root over the network and enforces the join-time ban
1934/// gate (a banned-never-cut v1 member who can open `m` is refused here, fail-closed). No
1935/// giftwrap to unwrap: the dissolution already authenticated the owner via its signature.
1936pub async fn accept_migration_material<T: Transport + ?Sized>(
1937    transport: &T,
1938    jm: &super::list::JoinMaterial,
1939) -> Result<CommunityV2, String> {
1940    let bundle = material_to_invite(jm);
1941    accept_bundle(transport, &bundle, None, true).await
1942}
1943
1944/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1945/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1946/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1947/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1948/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1949/// the newest valid Live by `created_at`. Read-only.
1950pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1951    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1952    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1953    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1954    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1955    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1956    let query = Query {
1957        kinds: vec![super::kind::INVITE_BUNDLE],
1958        authors: vec![parsed.link_signer.to_hex()],
1959        ..Default::default()
1960    };
1961    let relays = if parsed.bootstrap_relays.is_empty() {
1962        invite::stock_relays()
1963    } else {
1964        parsed.bootstrap_relays.clone()
1965    };
1966    // One bounded retry: a join fired while the pool is still warming (bootstrap
1967    // relays mid-handshake, routine during boot contention) reads back a transport
1968    // error, not an absent bundle. The pool add already happened on the first try,
1969    // so wait for a socket rather than guessing with a fixed sleep.
1970    let events = match transport.fetch(&query, &relays).await {
1971        Ok(evs) => evs,
1972        Err(_) => {
1973            wait_for_bootstrap_relay(&relays).await;
1974            transport.fetch(&query, &relays).await?
1975        }
1976    };
1977    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1978
1979    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1980    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1981    for event in &events {
1982        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1983            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1984            Ok(invite::BundleState::Live(bundle)) => {
1985                let at = event.created_at.as_secs();
1986                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1987                    newest_live = Some((at, *bundle));
1988                }
1989            }
1990            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1991        }
1992    }
1993    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1994}
1995
1996/// Wait — bounded — for ANY of the targets to report Connected before a retry:
1997/// the fetch's own warm path bounds its connect wait tighter than a cold TLS
1998/// handshake takes under boot contention.
1999async fn wait_for_bootstrap_relay(relays: &[String]) {
2000    let Some(client) = crate::state::nostr_client() else { return };
2001    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
2002    loop {
2003        for url in relays {
2004            if let Ok(Some(relay)) = client.relay(url).await {
2005                if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
2006                    return;
2007                }
2008            }
2009        }
2010        if tokio::time::Instant::now() >= deadline {
2011            return;
2012        }
2013        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
2014    }
2015}
2016
2017/// The most recent owner-root verification a PREVIEW completed, handed to a join
2018/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
2019/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
2020/// a different delivered root never matches. The join's own bundle re-fetch is
2021/// untouched, so the revocation gate always runs live.
2022struct VerifiedPreview {
2023    session: std::sync::Arc<crate::db::Session>,
2024    at: std::time::Instant,
2025    community_id: [u8; 32],
2026    community_root: [u8; 32],
2027    folded: CommunityV2,
2028    heads: Vec<FoldedHead>,
2029    /// The join-time authorized banlist from the SAME verified walk — carried so the
2030    /// handoff path keeps the ban gate (a preview-then-join must not skip it).
2031    banned: std::collections::BTreeSet<String>,
2032    /// Folded pins + banlist content from the same walk, so a preview-handoff join
2033    /// persists them exactly like a direct join.
2034    pins: Vec<(String, String, u64, String, u64)>,
2035    banlist_persist: Option<(Vec<String>, u64)>,
2036    roles: Option<(crate::community::roles::CommunityRoles, i64)>,
2037    authority_heads: Vec<FoldedHead>,
2038}
2039static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
2040const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
2041
2042/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
2043/// display metadata folded live from the Control Plane (a v2 bundle deliberately
2044/// carries no icon — the fold is the authority). Owner-root verification rides
2045/// the fold, so a forged-root link can't render a convincing preview; on a
2046/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
2047/// — the caller hasn't joined.
2048pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2049    let bundle = fetch_public_bundle(transport, url).await?;
2050    preview_bundle(transport, &bundle).await
2051}
2052
2053/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
2054/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
2055/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
2056/// it again regardless.
2057pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
2058    let community = CommunityV2::from_bundle(bundle, 0)?;
2059    match verify_owner_root_and_reconcile(transport, community.clone()).await {
2060        Ok(vj) => {
2061            let folded = vj.community;
2062            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
2063                session: crate::db::current_session(),
2064                at: std::time::Instant::now(),
2065                community_id: folded.id().0,
2066                community_root: folded.community_root,
2067                folded: folded.clone(),
2068                heads: vj.heads,
2069                banned: vj.banned,
2070                pins: vj.pins,
2071                banlist_persist: vj.banlist_persist,
2072                roles: vj.roles,
2073                authority_heads: vj.authority_heads,
2074            });
2075            Ok(folded)
2076        }
2077        Err(_) => Ok(community),
2078    }
2079}
2080
2081/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
2082pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2083    crate::db::scoped(async move {
2084        // Held across the network fetch so the join stays with one account.
2085        let bundle = fetch_public_bundle(transport, url).await?;
2086        accept_bundle(transport, &bundle, None, true).await
2087    })
2088    .await
2089}
2090
2091/// Leave a community: publish a Guestbook Leave and tear down the local hold.
2092pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2093    crate::db::scoped(async move {
2094        let signer = crate::signer::active_signer()?;
2095        let my_pk = me_pk()?;
2096        let at_ms = now_ms();
2097        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2098        let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
2099        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
2100            let _ = transport.publish(&wrap, &community.relays).await;
2101        }
2102        // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete.
2103        // Best-effort, but never silent: an unpublished tombstone is what lets a stale
2104        // copy of the list rejoin this community on a later boot.
2105        if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2106            crate::log_net_fail!("[CommunityList] leave tombstone failed ({}) — retrying in the background", e);
2107            tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2108        }
2109        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2110        Ok(())
2111    })
2112    .await
2113}
2114
2115/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
2116/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
2117/// `can_kick`), so publishing without authority is inert. A kicked member may
2118/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
2119pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
2120    crate::db::scoped(async move {
2121        assert_current_root(community)?;
2122        let signer = crate::signer::active_signer()?;
2123        let my_pk = me_pk()?;
2124        // Fast local pre-check; readers re-verify independently.
2125        let authority = fetch_authority(transport, community).await;
2126        let owner_hex = community.owner()?.to_hex();
2127        if !authority.roles.can_act_on_member(
2128            &my_pk.to_hex(),
2129            Some(&owner_hex),
2130            &target.to_hex(),
2131            crate::community::roles::Permissions::KICK,
2132        ) {
2133            return Err("not authorized to kick this member".to_string());
2134        }
2135        // CORD-04 §6 composition: a Kick is Role Removal THEN the directive — strip
2136        // first, so the target's rank is gone before the departure lands. Without it a
2137        // kicked admin leaves the memberlist still holding every management bit, and
2138        // every client keeps honoring their control editions.
2139        //
2140        // SKIPPED (not refused) when the strip isn't ours to make: a revoke needs
2141        // MANAGE_ROLES + strict outrank, and a KICK-only moderator still kicks — the
2142        // target just keeps their rank until an authorized strip lands. Each layer
2143        // validates on its own rule, so a missing one is a weaker removal, never a
2144        // broken one. A strip we DO attempt and lose is a hard error: proceeding would
2145        // publish a directive we know leaves rank behind.
2146        let target_hex = target.to_hex();
2147        let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2148        let may_strip = authority.roles.can_act_on_member(
2149            &my_pk.to_hex(),
2150            Some(&owner_hex),
2151            &target_hex,
2152            crate::community::roles::Permissions::MANAGE_ROLES,
2153        );
2154        if holds_roles && may_strip {
2155            grant_roles(transport, community, target, Vec::new())
2156                .await
2157                .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
2158        }
2159        let at_ms = now_ms();
2160        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2161        // A Kick is an authority action, so it cites its Grant like any other
2162        // (CORD-02 §5 / CORD-04 §5).
2163        let citation = required_authority_citation(community, &my_pk)?;
2164        let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2165        let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
2166            .map_err(|e| e.to_string())?;
2167        transport.publish(&wrap, &community.relays).await?;
2168        Ok(())
2169    })
2170    .await
2171}
2172
2173/// What a [`kick_members`] sweep actually managed to do.
2174#[derive(Debug, Default)]
2175pub struct KickSweep {
2176    pub kicked: Vec<PublicKey>,
2177    /// Targets the caller has no standing over, with the reason. Collected rather than
2178    /// returned as an error: one unkickable member must not sink the rest of the wave.
2179    pub refused: Vec<(PublicKey, String)>,
2180    /// Publishes that failed on the wire — transient, and safe to retry.
2181    pub failed: Vec<PublicKey>,
2182}
2183
2184/// Kick many members in one sweep.
2185///
2186/// A Kick is an individual directive, unlike a Banlist edition which names every npub in
2187/// one document, so the wire cost here is inherently linear and there is nothing to
2188/// batch. What this saves is everything around it: [`kick_member`] folds the whole
2189/// control plane per call, so kicking a raid one at a time spends a paged network fetch
2190/// per target. Authority, citation and the plane key are resolved once and reused.
2191///
2192/// `on_progress(done, total)` fires after every target, refusals included, so a caller
2193/// can drive a counter through a sweep that takes minutes.
2194pub async fn kick_members<T: Transport + ?Sized>(
2195    transport: &T,
2196    community: &CommunityV2,
2197    targets: &[PublicKey],
2198    on_progress: &(dyn Fn(usize, usize) + Sync),
2199) -> Result<KickSweep, String> {
2200    crate::db::scoped(async move {
2201        assert_current_root(community)?;
2202        let mut out = KickSweep::default();
2203        if targets.is_empty() {
2204            return Ok(out);
2205        }
2206        let signer = crate::signer::active_signer()?;
2207        let my_pk = me_pk()?;
2208        let my_hex = my_pk.to_hex();
2209        let owner_hex = community.owner()?.to_hex();
2210        // ONCE. This is the paged control-plane fetch that makes the per-member path
2211        // unusable in bulk.
2212        let authority = fetch_authority(transport, community).await;
2213        let citation = required_authority_citation(community, &my_pk)?;
2214        let gb_group =
2215            super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2216
2217        let total = targets.len();
2218        for (i, target) in targets.iter().enumerate() {
2219            let target_hex = target.to_hex();
2220            let outranks = |perm: u64| {
2221                authority.roles.can_act_on_member(&my_hex, Some(&owner_hex), &target_hex, perm)
2222            };
2223            if !outranks(crate::community::roles::Permissions::KICK) {
2224                out.refused.push((*target, "you do not outrank this member".to_string()));
2225                on_progress(i + 1, total);
2226                continue;
2227            }
2228            // CORD-04 §6: a Kick is Role Removal THEN the directive. Only a role-holder
2229            // pays for the strip, so a wave of plain members never touches the control
2230            // plane at all. A strip we attempt and lose skips the target rather than
2231            // publishing a directive we know leaves rank behind.
2232            let holds_roles =
2233                authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2234            if holds_roles && outranks(crate::community::roles::Permissions::MANAGE_ROLES) {
2235                if let Err(e) = grant_roles(transport, community, target, Vec::new()).await {
2236                    out.refused.push((*target, format!("could not strip roles before kicking: {e}")));
2237                    on_progress(i + 1, total);
2238                    continue;
2239                }
2240            }
2241            let at_ms = now_ms();
2242            let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2243            match guestbook::seal_guestbook_rumor_signed(
2244                &signer,
2245                my_pk,
2246                &rumor,
2247                &gb_group,
2248                Timestamp::from_secs(at_ms / 1000),
2249            )
2250            .await
2251            {
2252                Ok((wrap, _)) => match transport.publish(&wrap, &community.relays).await {
2253                    Ok(_) => out.kicked.push(*target),
2254                    Err(_) => out.failed.push(*target),
2255                },
2256                Err(_) => out.failed.push(*target),
2257            }
2258            on_progress(i + 1, total);
2259        }
2260        Ok(out)
2261    })
2262    .await
2263}
2264
2265/// A community's folded, delegation-authorized authority — the on-demand read
2266/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
2267/// owner-seeded authorized roster (shared algebra with v1); `banned` the
2268/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
2269/// entity (floored locally but no head folded) before replacing it blind.
2270pub struct AuthorityView {
2271    pub roles: crate::community::roles::CommunityRoles,
2272    pub banned: std::collections::BTreeSet<String>,
2273    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
2274    pub gapped: bool,
2275    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
2276    pub floored: std::collections::BTreeSet<String>,
2277    /// Authority entities (role/grant/banlist) that folded a head this fetch.
2278    pub head_entities: std::collections::BTreeSet<String>,
2279    /// Ban history (npub hex → secs), outliving the ban so an un-ban raises no phantom.
2280    pub banned_at: std::collections::BTreeMap<String, u64>,
2281}
2282
2283/// Fetch + fold the community's current authority (CORD-04), paging older like
2284/// `follow_control` while the fold is gapped so a busy control plane can't push
2285/// the roster off the newest window. A fetch failure degrades fail-safe:
2286/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
2287/// an outage, and a ban never lifts on withheld data.
2288pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
2289    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2290    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
2291        .unwrap_or_default()
2292        .into_iter()
2293        .filter(|(_, f)| f.0 == community.root_epoch.0)
2294        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2295        .collect();
2296    let control = control::ControlPlane::of(community);
2297
2298    let mut editions: Vec<ParsedEdition> = Vec::new();
2299    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2300    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2301    let mut oldest: Option<u64> = None;
2302    let mut until: Option<u64> = None;
2303    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
2304    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
2305    // error returns the stored bans (fail-safe), never an empty banlist that would
2306    // silently un-ban on withheld data.
2307    let mut a = fold_authority(community, &[], &floors);
2308    for _ in 0..FOLLOW_MAX_PAGES {
2309        // Quorum, DECLARED (the until→Full transport floor is gone): these
2310        // control reads tolerate a partial union — their fold semantics are
2311        // fail-safe on gaps (seeded banlists, withheld roster cache).
2312        let query = Query {
2313            kinds: vec![stream::KIND_WRAP],
2314            authors: vec![control.pk_hex()],
2315            until,
2316            limit: Some(FOLLOW_PAGE),
2317            evidence: crate::community::transport::Evidence::Quorum,
2318            ..Default::default()
2319        };
2320        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
2321        let mut fresh = 0usize;
2322        for w in &wraps {
2323            if !seen_wraps.insert(w.id) {
2324                continue;
2325            }
2326            fresh += 1;
2327            let at = w.created_at.as_secs();
2328            if oldest.is_none_or(|o| at < o) {
2329                oldest = Some(at);
2330            }
2331            if let Ok((ed, _)) = control.open(w) {
2332                if seen.insert(ed.inner_id) {
2333                    editions.push(ed);
2334                }
2335            }
2336        }
2337        a = fold_authority(community, &editions, &floors);
2338        if !a.gapped || fresh == 0 {
2339            break;
2340        }
2341        until = oldest;
2342    }
2343    AuthorityView {
2344        roles: a.roles,
2345        banned: a.banned,
2346        gapped: a.gapped,
2347        floored: floors.keys().cloned().collect(),
2348        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
2349        banned_at: a.banned_at,
2350    }
2351}
2352
2353/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
2354/// falls below `since_secs` (everything older is already held) or the plane is
2355/// exhausted. Returns the parsed events at/after the window plus the newest wrap
2356/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
2357///
2358/// PAGE bound rationale: a single 500-window silently drops a member whose Join
2359/// aged out (organic growth, or an insider flooding throwaway Joins), and
2360/// `refound_community` consumes the fold as its rekey recipient set — a dropped
2361/// member is SEVERED. Beyond this depth a community needs sharding (documented);
2362/// the granted-member union in [`fold_members`] is the consensus-complete
2363/// backstop regardless of Guestbook depth.
2364async fn fetch_guestbook_events<T: Transport + ?Sized>(
2365    transport: &T,
2366    community: &CommunityV2,
2367    since_secs: u64,
2368) -> Result<(Vec<guestbook::GuestbookEvent>, u64, bool), String> {
2369    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2370    const GB_PAGE: usize = 500;
2371    const GB_MAX_PAGES: usize = 12;
2372    let mut events = Vec::new();
2373    let mut newest: u64 = since_secs;
2374    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2375    let mut until: Option<u64> = None;
2376    let mut oldest: Option<u64> = None;
2377    // Did the walk run out of PLANE, or out of PAGES? Only the former means the
2378    // caller holds everything down to `since_secs` — advancing a cursor on the
2379    // latter skips whatever the walk never reached, permanently.
2380    let mut reached_end = false;
2381    for _ in 0..GB_MAX_PAGES {
2382        // Full: this set becomes the refound's recipient list — a member's
2383        // Join visible only on a minority relay must not be severed.
2384        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() };
2385        let wraps = transport.fetch(&query, &community.relays).await?;
2386        let mut fresh = 0usize;
2387        for wrap in &wraps {
2388            if !seen.insert(wrap.id) {
2389                continue;
2390            }
2391            fresh += 1;
2392            let at = wrap.created_at.as_secs();
2393            if oldest.is_none_or(|o| at < o) {
2394                oldest = Some(at);
2395            }
2396            if at > newest {
2397                newest = at;
2398            }
2399            // Older than the cursor window — already held; skip the decrypt.
2400            if at < since_secs {
2401                continue;
2402            }
2403            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
2404                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
2405                    events.push(ev);
2406                }
2407            }
2408        }
2409        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
2410            reached_end = true;
2411            break;
2412        }
2413        match oldest {
2414            Some(o) if o > 0 => until = Some(o),
2415            // Nothing older to ask for.
2416            _ => {
2417                reached_end = true;
2418                break;
2419            }
2420        }
2421    }
2422    Ok((events, newest, reached_end))
2423}
2424
2425/// The shared membership fold: coalesce Guestbook events under the community's
2426/// authority (owner-supreme kicks, refounder snapshots), union observed authors
2427/// plus every roster grantee, subtract the banlist, and pin the proven owner.
2428/// One implementation, so the live and stored reads can't drift.
2429/// Whose Guestbook snapshot seeds this community's CURRENT epoch (CORD-02 §5):
2430/// "honored only from the npub whose Refounding minted that epoch". That is the
2431/// owner only when the owner did the refounding.
2432///
2433/// Vector read it as owner-always, so an admin's — or a moderation bot's —
2434/// refounding rolled the epoch and vended the keys while its survivor list was
2435/// discarded. Every client then folded its own accumulated Joins instead: members
2436/// the containment had cut lingered on the roster, and no two clients agreed on it.
2437///
2438/// The minter comes from the rotation this device VERIFIED and adopted, never from
2439/// the snapshot, so writing a snapshot grants nobody authority over it. An epoch
2440/// adopted before minters were recorded has no entry and falls back to the owner —
2441/// the previous behaviour, never a wider one. Genesis (epoch 0) has no refounder and
2442/// therefore no snapshot power at all.
2443fn snapshot_authority_for(cid_hex: &str, community: &CommunityV2, owner: &PublicKey) -> Option<PublicKey> {
2444    if community.root_epoch.0 == 0 {
2445        return None;
2446    }
2447    let minted_by = epoch_minters(cid_hex)
2448        .get(&community.root_epoch.0)
2449        .and_then(|h| PublicKey::from_hex(h).ok());
2450    Some(minted_by.unwrap_or(*owner))
2451}
2452
2453fn fold_members(
2454    community: &CommunityV2,
2455    events: &[guestbook::GuestbookEvent],
2456    mut observed: std::collections::BTreeMap<PublicKey, u64>,
2457    roles: &crate::community::roles::CommunityRoles,
2458    banlist: &std::collections::BTreeSet<PublicKey>,
2459    banned_at: &std::collections::BTreeMap<PublicKey, u64>,
2460) -> Result<Vec<PublicKey>, String> {
2461    let owner = community.owner()?;
2462    let owner_hex = owner.to_hex();
2463    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2464
2465    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
2466    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
2467    // even if their Join aged out of the Guestbook entirely and they never posted.
2468    // This is what keeps a Refounding from severing a lurking admin. `observed`
2469    // carries them at ts 0 (presence, not recency); the banlist subtraction below
2470    // still removes a banned grantee whose grant wasn't yet stripped.
2471    for g in &roles.grants {
2472        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
2473            observed.entry(pk).or_insert(0);
2474        }
2475    }
2476
2477    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
2478    // new epoch's Guestbook with a 3312 snapshot of the survivors. Only the OWNER's snapshot is
2479    // honored here, so a silent survivor stays in the memberlist across an owner refound
2480    // without re-posting. A genesis community (root_epoch 0) has no refounder, hence no
2481    // snapshot power. KNOWN GAP (do not "fix" unilaterally — CORD-04/06 + Armada): the refound
2482    // send/receive gates authorize any BAN-holder to refound, but their snapshot is NOT honored
2483    // here, so a non-owner admin's refound drops silent survivors (incl. migration roster seeds)
2484    // until they re-post. Binding the minting rotator into snapshot authority is a spec change.
2485    let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2486    let snapshot_authority = snapshot_authority_pk.as_ref();
2487    // Kick authority (CORD-04 §5/§6): the signer must cite a Grant we've synced AND
2488    // hold KICK AND strictly outrank the target (the owner is supreme; equal cannot
2489    // kick equal).
2490    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2491        let actor_hex = actor.to_hex();
2492        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2493            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2494    };
2495    let now = now_ms();
2496    let coalesced = guestbook::coalesce(events, now, snapshot_authority, &can_kick);
2497    // The live refound's guest list also gates the observed-forward path in
2498    // complete_memberlist, so a raid spammer whose Kick was buried by the rotation
2499    // isn't silently re-admitted by their old spam.
2500    let refound = guestbook::live_refound(events, snapshot_authority, now);
2501    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at, refound.as_ref());
2502    // The owner is a member by definition, independent of any fetched Join.
2503    if !banlist.contains(&owner) {
2504        members.insert(owner);
2505    }
2506    Ok(members.into_iter().collect())
2507}
2508
2509
2510/// What a [`rescue_stranded_members`] pass published.
2511#[derive(Debug, Default, serde::Serialize)]
2512pub struct RescueReport {
2513    /// Epoch hops that received catch-up blobs (1..=current, minus any with a missing root).
2514    pub hops: usize,
2515    /// Hops skipped because an archived root was missing — those members stay wedged there.
2516    pub hops_skipped: usize,
2517    pub chunks_published: usize,
2518    pub targets: usize,
2519}
2520
2521/// Re-vend the epoch chain to members a past rotation forgot.
2522///
2523/// DELIBERATELY not surfaced in any client: [`refound_community`]'s recipient union
2524/// exists so this situation cannot arise from our own rotations. This stays as the
2525/// operator/SDK primitive for strandings we don't control — another client's rotation
2526/// folding a narrower memberlist, or a pre-union build. It re-keyed 39 live members
2527/// once; prevention is the product, this is the fire axe.
2528///
2529/// A client follows rekeys hop by hop: from its held root it derives ONE plane —
2530/// `base_rekey_group(held_root, cid, held_epoch + 1)` — and can never jump. A member
2531/// excluded from the recipient set at hop N therefore wedges at N−1 forever; no later
2532/// rotation can reach them because they cannot even derive where to look.
2533///
2534/// The rotator holds every archived root, so it can publish what should have existed:
2535/// for each hop, additional blobs addressed to the stranded set, built with the SAME
2536/// (rotator, scope, epoch, prev-commitment) as the original rotation. `collect_rotations`
2537/// correlates on exactly that key, so the catch-up blobs merge into the rotation every
2538/// client already validated — the stranded client's next follow simply finds its blob
2539/// and walks the whole chain forward. No action needed on their side; works on any
2540/// client version.
2541///
2542/// Intermediate blobs carry the CURRENT control pair (the historical pairs are not
2543/// archived). A mid-walk client briefly holds a future control address, and the final
2544/// hop — which every walk ends on — sets the same, correct pair.
2545///
2546/// A Guestbook snapshot addendum at the current epoch re-seeds the rescued members for
2547/// outside readers (the invite page) and for clients whose fold lost them.
2548pub async fn rescue_stranded_members<T: Transport + ?Sized>(
2549    transport: &T,
2550    community: &CommunityV2,
2551    targets: &[PublicKey],
2552) -> Result<RescueReport, String> {
2553    crate::db::scoped(async move {
2554        let mut report = RescueReport { targets: targets.len(), ..Default::default() };
2555        if targets.is_empty() {
2556            return Ok(report);
2557        }
2558        let signer = crate::signer::active_signer()?;
2559        let my_pk = me_pk()?;
2560        let cid = community.id();
2561        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2562        let owner = community.owner()?;
2563        let owner_hex = owner.to_hex();
2564        // Key-granting is rotation-shaped authority: the receive side only honors blobs
2565        // inside rotations whose rotator it admits, so an unauthorized caller could only
2566        // publish noise — but fail loudly here rather than publish it.
2567        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2568        if my_pk != owner && !roster.is_authorized(&my_pk.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN) {
2569            return Err("rescuing stranded members requires rotation authority (owner or BAN)".to_string());
2570        }
2571        let control_pk = community.control_pk.ok_or("rescue requires a split community (no control pk held)")?;
2572        let citation = my_authority_citation(community, &my_pk);
2573
2574        // Every archived root, plus the live head (belt-and-suspenders: the head is
2575        // archived at rotation time, but the union costs nothing).
2576        let mut roots: std::collections::BTreeMap<u64, [u8; 32]> =
2577            crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)?
2578                .into_iter()
2579                .map(|(e, k)| (e.0, k))
2580                .collect();
2581        roots.insert(community.root_epoch.0, community.community_root);
2582
2583        let at_secs = now_ms() / 1000;
2584        for e in 1..=community.root_epoch.0 {
2585            let (Some(prev_root), Some(new_root)) = (roots.get(&(e - 1)).copied(), roots.get(&e).copied()) else {
2586                crate::log_warn!("[Rescue] no archived root for hop {} -> {} — members wedged there stay wedged", e - 1, e);
2587                report.hops_skipped += 1;
2588                continue;
2589            };
2590            let group = super::derive::base_rekey_group_key(&prev_root, cid, Epoch(e));
2591            let prev_commit = super::derive::epoch_key_commitment(Epoch(e - 1), &prev_root);
2592            // The catch-up EXTENDS the original rotation's chunk set: same correlation
2593            // key, indices past its highest slot. Re-claiming a slot is not an option —
2594            // collect_rotations keeps the first chunk per index, and if ours won, the
2595            // original recipients in the losing chunk would fold as removed.
2596            let existing = fetch_rekey_chunks(transport, &community.relays, &group).await?;
2597            let start_after = existing
2598                .iter()
2599                .filter(|c| {
2600                    c.rotator == my_pk
2601                        && c.scope == super::rekey::RekeyScope::Root
2602                        && c.new_epoch == Epoch(e)
2603                        && c.prev_commit == prev_commit
2604                })
2605                .map(|c| c.chunk.0.max(c.chunk.1))
2606                .max()
2607                .unwrap_or(0);
2608            // The owner rides every catch-up chunk: an extension inherits the original
2609            // chunks' owner blob, but a plane a relay withheld folds ours standalone,
2610            // and a rotation without an owner blob is inadmissible by design.
2611            let mut recipients: Vec<PublicKey> = targets.to_vec();
2612            if !recipients.contains(&my_pk) {
2613                recipients.push(my_pk);
2614            }
2615            let mut blobs = Vec::with_capacity(recipients.len());
2616            for r in &recipients {
2617                // Staff get the control secret exactly as a live rotation would give it.
2618                let staff = *r == my_pk || roster.is_staff(&r.to_hex(), Some(&owner_hex));
2619                let ctl_root = if staff { community.control_root.as_ref() } else { None };
2620                blobs.push(
2621                    super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, Epoch(e), &new_root, &control_pk.to_bytes(), ctl_root)
2622                        .await
2623                        .map_err(|e| e.to_string())?,
2624                );
2625            }
2626            // A rescue extension can fold STANDALONE on a withheld plane, so it
2627            // must re-state the hop's sever itself: from the fetched siblings, or
2628            // from the persisted mark when the plane withheld even those.
2629            let severed_hop = existing
2630                .iter()
2631                .any(|c| c.scope == super::rekey::RekeyScope::Root && c.new_epoch == Epoch(e) && c.severed)
2632                || sever_marks(&cid_hex).contains_key(&e);
2633            let chunks = super::rekey::build_rekey_chunk_extension(
2634                &signer,
2635                my_pk,
2636                &group,
2637                super::rekey::RekeyScope::Root,
2638                Epoch(e),
2639                Epoch(e - 1),
2640                &prev_commit,
2641                &blobs,
2642                at_secs,
2643                citation.as_ref(),
2644                severed_hop,
2645                start_after,
2646            )
2647            .await
2648            .map_err(|e| e.to_string())?;
2649            for c in &chunks {
2650                transport.publish_durable(c, &community.relays).await?;
2651                report.chunks_published += 1;
2652            }
2653            report.hops += 1;
2654        }
2655
2656        // Re-seed them in the current epoch's Guestbook: snapshot chunks are
2657        // independently useful (CORD-02 §5), so an addendum under a fresh snap id folds
2658        // as Joined on every client and every outside reader. Best-effort, like a
2659        // rotation's own snapshot.
2660        let gb_group = super::derive::guestbook_group_key(&community.community_root, cid, community.root_epoch);
2661        let snap_id = crate::community::random_32();
2662        for rumor in guestbook::build_snapshot_rumors(my_pk, targets, snap_id, at_secs * 1000) {
2663            if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2664                let _ = transport.publish(&wrap, &community.relays).await;
2665            }
2666        }
2667
2668        crate::log_info!(
2669            "[Rescue] {} member(s): {} hop(s) re-vended ({} skipped), {} chunk(s) published, snapshot addendum at epoch {}",
2670            targets.len(),
2671            report.hops,
2672            report.hops_skipped,
2673            report.chunks_published,
2674            community.root_epoch.0
2675        );
2676        Ok(report)
2677    })
2678    .await
2679}
2680
2681/// Diagnostic: the memberlist as the WIRE alone tells it — the current epoch's
2682/// guestbook plane fetched fresh and folded with no local observation and no roster
2683/// backstop. This is what an outside reader (the invite page) computes, so the diff
2684/// against [`stored_memberlist`] is exactly the set a rotation left behind: counted
2685/// locally, absent from the snapshot, holding no current key.
2686pub async fn wire_guestbook_members<T: Transport + ?Sized>(
2687    transport: &T,
2688    community: &CommunityV2,
2689) -> Result<Vec<PublicKey>, String> {
2690    let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2691    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2692    let owner = community.owner()?;
2693    let owner_hex = owner.to_hex();
2694    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2695    let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2696    let snapshot_authority = snapshot_authority_pk.as_ref();
2697    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2698        let actor_hex = actor.to_hex();
2699        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2700            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2701    };
2702    let coalesced = guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick);
2703    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2704        .unwrap_or_default()
2705        .iter()
2706        .filter_map(|h| PublicKey::from_hex(h).ok())
2707        .collect();
2708    Ok(coalesced
2709        .iter()
2710        .filter(|(pk, st)| st.verdict == guestbook::Verdict::Joined && !banlist.contains(pk))
2711        .map(|(pk, _)| *pk)
2712        .collect())
2713}
2714
2715/// Did the AUTHORIZED Guestbook coalesce rule `member` KICKED, per the stored plane?
2716///
2717/// This is the only sound basis for acting on a kick against ourselves. The
2718/// memberlist is the wrong question: it also folds the banlist, the ban marks and
2719/// observed authors, so a member whose Guestbook hasn't caught up yet — a REJOIN,
2720/// where the store starts empty while the control fold has already re-derived their
2721/// old ban mark — is absent from it while being perfectly joined. Coalescing asks
2722/// only "what is the latest authorized entry for this npub", so a fresh Join
2723/// supersedes an old Kick and an empty store yields no verdict at all.
2724pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2725    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2726    let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2727        return false;
2728    };
2729    let Ok(owner) = community.owner() else { return false };
2730    let owner_hex = owner.to_hex();
2731    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2732    let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2733    let snapshot_authority = snapshot_authority_pk.as_ref();
2734    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2735        let actor_hex = actor.to_hex();
2736        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2737            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2738    };
2739    matches!(
2740        guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2741        Some(st) if st.verdict == guestbook::Verdict::Kicked
2742    )
2743}
2744
2745/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
2746/// from zero). The fetch straddles the network, so the session re-checks before
2747/// the store writes. Returns the events that were NEW to the store — the caller
2748/// surfaces them (presence lines) and refreshes on non-empty.
2749pub async fn sync_guestbook<T: Transport + ?Sized>(
2750    transport: &T,
2751    community: &CommunityV2,
2752) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2753    crate::db::scoped(async move {
2754        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2755        let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2756        // First sync of the session walks the WHOLE plane; later ones ride the cursor.
2757        // The cursor alone can only ever move forward, so any history a walk missed —
2758        // a page cap, an open breaker, stream-auth not yet landed, one relay of several
2759        // answering — is skipped for good, and two devices settle on different member
2760        // counts that never converge. Re-walking each session heals that, and it also
2761        // covers what no persisted flag can: a walk ends on a short page when the relays
2762        // that ANSWERED had no more, which is not the same as the plane being exhausted.
2763        let full = !guestbook_walked_this_session(&cid_hex);
2764        // Overlap one second so a same-second boundary event can't slip the cursor;
2765        // the rumor-id merge below dedups the re-fetched edge.
2766        let since = if full { 0 } else { cursor.saturating_sub(1) };
2767        let (fresh, newest, reached_end) = fetch_guestbook_events(transport, community, since).await?;
2768        // Every event off this fetch came from the plane we hold the key to. The stamp is
2769        // what lets the fold tell a Join carried over from a buried epoch apart from a
2770        // live one; a row stored before stamping existed earns its stamp on re-read.
2771        let live = Some(community.root_epoch.0);
2772        let mut by_id: std::collections::HashMap<[u8; 32], usize> = events.iter().enumerate().map(|(i, e)| (e.rumor_id, i)).collect();
2773        let mut added = Vec::new();
2774        let mut restamped = false;
2775        for mut ev in fresh {
2776            ev.epoch = live;
2777            match by_id.get(&ev.rumor_id) {
2778                Some(&i) => {
2779                    if events[i].epoch.is_none() {
2780                        events[i].epoch = live;
2781                        restamped = true;
2782                    }
2783                }
2784                None => {
2785                    // First sight on THIS client. The author writes `at_ms`; they do
2786                    // not write this.
2787                    let mut ev = ev;
2788                    if ev.observed_at.is_none() {
2789                        ev.observed_at = Some(
2790                            std::time::SystemTime::now()
2791                                .duration_since(std::time::UNIX_EPOCH)
2792                                .map(|d| d.as_secs())
2793                                .unwrap_or(0),
2794                        );
2795                    }
2796                    by_id.insert(ev.rumor_id, events.len());
2797                    events.push(ev.clone());
2798                    added.push(ev);
2799                }
2800            }
2801        }
2802        // Advance ONLY on a walk that ran out of plane. `newest` counts every wrap seen,
2803        // including ones skipped or that failed to open, so moving it after a truncated
2804        // walk is what buries the events that walk never reached.
2805        let advanced = if reached_end { newest.max(cursor) } else { cursor };
2806        if !added.is_empty() || advanced > cursor || restamped {
2807            crate::db::community::set_guestbook(&cid_hex, &events, advanced)?;
2808            mark_guestbook_observed_since(&cid_hex);
2809        }
2810        if full && reached_end {
2811            mark_guestbook_walked(&cid_hex);
2812        }
2813        Ok(added)
2814    })
2815    .await
2816}
2817
2818/// Communities whose Guestbook plane has been walked end-to-end this session.
2819/// Lives on the Session, so an account swap drops it and the next account walks
2820/// its own rather than inheriting this one's coverage.
2821struct GuestbookWalked;
2822
2823fn guestbook_walked_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
2824    crate::db::current_session().scoped::<GuestbookWalked, _>()
2825}
2826
2827fn guestbook_walked_this_session(community_id: &str) -> bool {
2828    guestbook_walked_set().lock().is_ok_and(|s| s.contains(community_id))
2829}
2830
2831fn mark_guestbook_walked(community_id: &str) {
2832    if let Ok(mut s) = guestbook_walked_set().lock() {
2833        s.insert(community_id.to_string());
2834    }
2835}
2836
2837/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
2838/// Returns whether it was new.
2839pub fn ingest_guestbook_event(community: &CommunityV2, mut ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2840    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2841    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2842    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2843        return Ok(false);
2844    }
2845    ev.epoch = Some(community.root_epoch.0);
2846    events.push(ev);
2847    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2848    Ok(true)
2849}
2850
2851/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
2852/// observed authors (the synced events DB), plus roster grantees, minus the
2853/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
2854/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
2855/// remains the authoritative walk — a refounding's rekey recipient set must
2856/// never trust a possibly-stale store.
2857/// Unix seconds from which this client has been following `cid_hex`'s guestbook.
2858///
2859/// Set once, on the first sync that stores anything, and never moved forward. It is
2860/// what makes `observed_at` usable as evidence: a client that only started watching
2861/// AFTER a raid began sees every member as new, so its observations prove nothing and
2862/// must not be allowed to evict anyone.
2863fn guestbook_observed_since(cid_hex: &str) -> Option<u64> {
2864    crate::db::settings::get_sql_setting(format!("gb_observed_since:{cid_hex}"))
2865        .ok()
2866        .flatten()
2867        .and_then(|v| v.parse::<u64>().ok())
2868}
2869
2870fn mark_guestbook_observed_since(cid_hex: &str) {
2871    let key = format!("gb_observed_since:{cid_hex}");
2872    if crate::db::settings::get_sql_setting(key.clone()).ok().flatten().is_some() {
2873        return; // never moves forward — a later stamp would erase our own coverage
2874    }
2875    let now = std::time::SystemTime::now()
2876        .duration_since(std::time::UNIX_EPOCH)
2877        .map(|d| d.as_secs())
2878        .unwrap_or(0);
2879    let _ = crate::db::settings::set_sql_setting(key, now.to_string());
2880}
2881
2882pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2883    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2884    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2885    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2886    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2887        if let Ok(pk) = PublicKey::parse(&npub) {
2888            observed.insert(pk, last_active_secs.saturating_mul(1000));
2889        }
2890    }
2891    let roles = crate::db::community::get_community_roles(&cid_hex)?;
2892    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2893        .unwrap_or_default()
2894        .iter()
2895        .filter_map(|h| PublicKey::from_hex(h).ok())
2896        .collect();
2897    // Ban history outlives the banlist itself — see [`fold_members`]. Read from the store,
2898    // since this path never folds editions.
2899    let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2900        .unwrap_or_default()
2901        .into_iter()
2902        .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2903        .collect();
2904    fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2905}
2906
2907/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
2908/// ALWAYS a member (derived from the self-certifying community_id — no network,
2909/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
2910/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
2911/// member whose Join was lost still counts.
2912pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2913    let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2914    // Observed authors: fold each held channel's recent authorship (real author +
2915    // newest ms), so a member who posted but whose Join was lost is still counted.
2916    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2917    for ch in &community.channels {
2918        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2919            for f in &page {
2920                let e = observed.entry(f.event.opened().author).or_insert(0);
2921                *e = (*e).max(f.event.opened().at_ms);
2922            }
2923        }
2924    }
2925
2926    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
2927    // ban subtraction. A control fetch failure degrades to owner-only authority + no
2928    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
2929    // and a missed ban only fails to HIDE, never to wrongly admit authority).
2930    let authority = fetch_authority(transport, community).await;
2931    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
2932    let banlist: std::collections::BTreeSet<PublicKey> =
2933        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2934    // Union the live fold's ban history with the stored marks: the fetch only reaches the
2935    // editions still in its window, and a ban that aged out is exactly the one whose
2936    // pre-ban Join would phantom.
2937    let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2938        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2939    )
2940    .unwrap_or_default()
2941    .into_iter()
2942    .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2943    .collect();
2944    for (h, at) in &authority.banned_at {
2945        if let Ok(pk) = PublicKey::from_hex(h) {
2946            let slot = banned_at.entry(pk).or_insert(0);
2947            *slot = (*slot).max(*at);
2948        }
2949    }
2950    fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2951}
2952
2953// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
2954
2955/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
2956/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
2957/// past or present member resolves the same grave and a Refounding can never strand
2958/// it). The tombstone's presence IS the state; only the owner's seal counts.
2959/// Irreversible — on success the local hold is sealed read-only AND the owner's
2960/// own membership is tombstoned out of the §8 List: dissolving is also leaving,
2961/// so the owner's other devices tear the community down on their next list sync
2962/// instead of keeping a sealed husk they believe is still held.
2963pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2964    crate::db::scoped(async move {
2965        let signer = crate::signer::active_signer()?;
2966        let my_pk = me_pk()?;
2967        if community.owner()? != my_pk {
2968            return Err("only the owner can dissolve a community".to_string());
2969        }
2970        let at_ms = now_ms();
2971        let at = at_ms / 1000;
2972        let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2973        let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2974        // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
2975        transport.publish_durable(&wrap, &community.relays).await?;
2976        crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2977        // Dissolving IS leaving (same discipline as `leave_community`): retire the
2978        // membership from the cross-device List in the same breath as the grave.
2979        // Without this the tombstone never reaches kind 33302 — the teardown above
2980        // this call only ever spoke the retired v1 mirror — so every sibling device
2981        // and every §8 client kept the entry LIVE, folded the grave, and showed a
2982        // dissolved husk that had to be removed by hand. Best-effort with a durable
2983        // retry, never failing the dissolve: the grave is already published, and an
2984        // unrecorded leave only costs the husk lingering until the retry lands.
2985        if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2986            crate::log_net_fail!("[CommunityList] dissolve tombstone failed ({}) — retrying in the background", e);
2987            tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2988        }
2989        Ok(())
2990    })
2991    .await
2992}
2993
2994/// Whether a valid owner-signed dissolution tombstone exists for this community on
2995/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
2996/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
2997/// owner-verified tombstone found is authoritative.
2998pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2999    let group = super::derive::dissolved_group_key(community.id());
3000    let query = Query {
3001        kinds: vec![stream::KIND_WRAP],
3002        authors: vec![group.pk_hex()],
3003        limit: Some(20),
3004        ..Default::default()
3005    };
3006    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
3007        return false;
3008    };
3009    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
3010}
3011
3012// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
3013
3014/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
3015/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
3016/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
3017/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
3018/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
3019/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
3020///
3021/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
3022/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
3023/// transient miss never strands a published rekey with a half-anchored plane.
3024pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
3025    refound_community_with(transport, community, removed, false, 0, None).await
3026}
3027
3028/// A SEVERING Refounding (CORD-06 Severing): the rotation carries the `sever`
3029/// marker, and every adopter — this client included — revokes its own public
3030/// invite links instead of refreshing them into the new epoch. The read-cut is
3031/// real even while the community was Public: the door closes with the rotation,
3032/// and only a deliberate fresh mint re-opens it.
3033pub async fn refound_community_severing<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], joined_after_cut: u64) -> Result<CommunityV2, String> {
3034    refound_community_with(transport, community, removed, true, joined_after_cut, None).await
3035}
3036
3037/// `joined_after_cut`: seconds. A member whose EARLIEST Join is at or after this is
3038/// vended no key. 0 disables it. The test is positive evidence only — a member with no
3039/// Join on the reachable guestbook (buried epoch, aged-out window) is NOT excluded, and
3040/// an unreadable guestbook excludes nobody. Absence must never evict: five rotations
3041/// once shed 39 real members that way.
3042/// Re-found vending keys to `retain` ONLY (plus the owner and this device).
3043///
3044/// An operator picked a list; anyone not on it gets nothing, whenever they arrived.
3045/// The removal-list form cannot express that: it is computed from the memberlist at
3046/// one instant while the refound fetches its own later, so an account that appears in
3047/// between is on neither list and is vended a key by default — implicitly RETAINED by
3048/// the very purge meant to be exhaustive. An allow-list has no such gap, and unlike a
3049/// join-time cut it is immune to a backdated (author-claimed) Join.
3050pub async fn refound_community_retaining<T: Transport + ?Sized>(
3051    transport: &T,
3052    community: &CommunityV2,
3053    removed: &[PublicKey],
3054    retain: &[PublicKey],
3055) -> Result<CommunityV2, String> {
3056    refound_community_with(transport, community, removed, false, 0, Some(retain)).await
3057}
3058
3059async fn refound_community_with<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], sever: bool, joined_after_cut: u64, allow_only: Option<&[PublicKey]>) -> Result<CommunityV2, String> {
3060    crate::db::scoped(async move {
3061        let cid = community.id();
3062        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3063        // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
3064        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3065            return Err("this community has been dissolved; it cannot be re-founded".to_string());
3066        }
3067        let signer = crate::signer::active_signer()?;
3068        let my_pk = me_pk()?;
3069        // Serialize with the follow worker for the whole rotation: the commit tail
3070        // whole-row-saves, and an unserialized concurrent follow could otherwise be
3071        // rolled back (or adopt a half-published sibling of this very rotation).
3072        let lock = super::realtime::follow_lock(cid);
3073        let _guard = lock.lock().await;
3074        // Reload the FRESHEST base state: a stale caller struct would address the rotation
3075        // under a superseded root (a base fork with no heal). The community_id is
3076        // self-certifying + stable, so re-loading by it is safe.
3077        let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
3078        let community = &fresh;
3079        let owner = community.owner()?;
3080
3081        // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
3082        // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
3083        // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
3084        // and the banlist authority fold: any admin holding BAN may re-found, checked
3085        // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
3086        // only the owner able to re-found.
3087        {
3088            let owner_hex = owner.to_hex();
3089            let me_hex = my_pk.to_hex();
3090            // Persisted (last-folded) roster — the receive side is authoritative, so
3091            // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
3092            // collapses to owner-only, which can only OVER-restrict a fresh admin whose
3093            // grant hasn't folded into their own DB (the caller's ban flow folds control
3094            // first). It can never grant authority no one has.
3095            let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3096            let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3097            let authorized = my_pk == owner
3098                || (!banned.contains(&me_hex)
3099                    && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
3100                    && removed.iter().all(|t| {
3101                        roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
3102                    }));
3103            if !authorized {
3104                return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
3105            }
3106        }
3107
3108        // Fold the current roster: the opened editions are reused for the compaction (their
3109        // seals re-wrap under the new epoch), and the roster gates which admin-authored
3110        // heads carry forward.
3111        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3112            .into_iter()
3113            .filter(|(_, f)| f.0 == community.root_epoch.0)
3114            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3115            .collect();
3116        let current_control = control::ControlPlane::of(community);
3117        // Page the ENTIRE control plane, not just the newest window: the compaction MUST
3118        // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
3119        // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
3120        // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
3121        // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
3122        // at the new epoch a fresh joiner bootstraps.
3123        let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3124        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3125        let mut oldest: Option<u64> = None;
3126        let mut until: Option<u64> = None;
3127        // Read to EXHAUSTION, not to coverage: an entity with no floor yet (a
3128        // first-ever Banlist published while we were away) is invisible to a
3129        // coverage test, so stopping there could compact it away.
3130        let mut truncated = false;
3131        for page in 0..COMPACT_MAX_PAGES {
3132            // Full: compaction re-wraps the head set it can SEE — a control
3133            // edition (a ban head) reachable only on a minority relay must not be
3134            // compacted away by a partial union.
3135            let query = Query {
3136                kinds: vec![stream::KIND_WRAP],
3137                authors: vec![current_control.pk_hex()],
3138                until,
3139                limit: Some(FOLLOW_PAGE),
3140                evidence: crate::community::transport::Evidence::Full,
3141                ..Default::default()
3142            };
3143            let wraps = transport.fetch(&query, &community.relays).await?;
3144            let mut fresh = 0usize;
3145            for w in &wraps {
3146                if !seen_wraps.insert(w.id) {
3147                    continue;
3148                }
3149                fresh += 1;
3150                let at = w.created_at.as_secs();
3151                if oldest.is_none_or(|o| at < o) {
3152                    oldest = Some(at);
3153                }
3154                if let Ok(parsed) = current_control.open(w) {
3155                    opened.push(parsed);
3156                }
3157            }
3158            if fresh == 0 {
3159                // `until` is inclusive: a FULL page with nothing new is a same-second
3160                // wall no cursor steps past, so older editions stay unreachable. A
3161                // short page is simply the end of the plane.
3162                truncated = wraps.len() >= FOLLOW_PAGE;
3163                break;
3164            }
3165            until = oldest;
3166            if page + 1 == COMPACT_MAX_PAGES {
3167                truncated = true;
3168            }
3169        }
3170        if truncated {
3171            return Err(
3172                "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(),
3173            );
3174        }
3175
3176        let prev_epoch = community.root_epoch;
3177        let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
3178        let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3179        // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
3180        // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
3181        // it can't double-mint two roots a receiver's correlation dedup would collapse into
3182        // a permanent fork (CORD-06 §3 idempotency). The compaction fetch above straddled
3183        let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3184        // Every compliant base rotation mints the split beside the root (CORD-06
3185        // §3) — a legacy community upgrades as a side effect of this Refounding.
3186        // Reserved under the same retry-idempotency: both attempts must deliver ONE
3187        // pair, or staff adopt whichever secret rode the chunk with their locator.
3188        let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3189        let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3190        // The new epoch's Control plane is SPLIT: wraps sign with the fresh
3191        // control_root-derived signer and encrypt under the new community_root-
3192        // derived read key. Editions are never ALSO mirrored to the legacy-derived
3193        // address — that would re-open exactly the member-writable surface the
3194        // split closes (CORD-06 §3).
3195        let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3196        let at = now_ms();
3197        let at_secs = at / 1000;
3198
3199        // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
3200        // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
3201        // including entities the metadata/roster folds don't touch (the invite Registry
3202        // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
3203        // can't be folded (buried past the pager / withheld) ABORTS before any publish.
3204        use std::collections::BTreeMap;
3205        let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3206        for (i, (e, _)) in opened.iter().enumerate() {
3207            by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3208        }
3209        let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3210        for (floor_key, floor) in &floors {
3211            // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
3212            // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
3213            // so matching it is authority-correct across EVERY entity type. `fold_head`'s
3214            // version-chain TIP is author-BLIND: a member can seal a forged higher-version
3215            // edition chaining onto the floor, which the tip would carry and honest folders
3216            // then DROP as unauthorized — silently suppressing that role/grant/banlist across
3217            // the refounding. Abort if the committed head isn't served (fail-closed).
3218            let head_idx = by_eid
3219                .get(floor_key)
3220                .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3221            let Some(head_idx) = head_idx else {
3222                return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
3223            };
3224            let (head_ed, head_os) = &opened[head_idx];
3225            let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3226            let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3227            carried.push((h, rewrapped));
3228        }
3229
3230        // Recipients: the current members minus `removed`, plus me (multi-device).
3231        //
3232        // UNION of the network fold and the LOCAL memberlist. The network fold only
3233        // observes the recent window, so a quiet member whose Join lives on a buried
3234        // epoch falls out of it — and out of the key set and snapshot — on every
3235        // rotation. Five rotations in one evening shed 39 real members that way, each
3236        // left wedged at the hop that first forgot them. Locally-known members are
3237        // vended keys too; being forgotten is not a removal, only `removed` is.
3238        let members = memberlist(transport, community).await?;
3239        let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
3240        let net_len = members.len();
3241        let mut union: std::collections::BTreeSet<PublicKey> = members.into_iter().collect();
3242        let local = stored_memberlist(community)?;
3243        let local_len = local.len();
3244        union.extend(local);
3245        let mut recipients: Vec<PublicKey> = union.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
3246        if !recipients.iter().any(|p| *p == my_pk) {
3247            recipients.push(my_pk);
3248        }
3249
3250        // The freshest roster reachable, for two per-recipient decisions below:
3251        // channel entitlement AND staff-ness (who receives the 136-byte blob).
3252        // Entitlement must come from a CURRENT roster, not the last-folded cache:
3253        // the base recipients above are a fresh network fold, and mixing the two
3254        // strands anyone granted since this client last folded — they keep a dead
3255        // key and the new epoch's rekey plane carries no blob for them. Fetched,
3256        // then merged over the cache so a role we published ourselves survives too.
3257        let mut roster_for_channels = fetch_authority(transport, community).await.roles;
3258        {
3259            let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3260            for r in cached.roles {
3261                if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
3262                    roster_for_channels.roles.push(r);
3263                }
3264            }
3265            for g in cached.grants {
3266                if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
3267                    roster_for_channels.grants.push(g);
3268                }
3269            }
3270        }
3271        let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
3272
3273        // Operator purge: vend ONLY to the chosen list.
3274        //
3275        // Not a time test — a hard allow-list, so an account that arrived between the
3276        // operator's selection and this fetch is excluded by NOT BEING ON IT, with no
3277        // window to slip through and nothing to backdate. Owner and this device are
3278        // always kept: a purge must not orphan the community or lock out the operator
3279        // running it.
3280        if let Some(allow) = allow_only {
3281            let allowed: std::collections::HashSet<[u8; 32]> = allow.iter().map(|p| p.to_bytes()).collect();
3282            let before = recipients.len();
3283            recipients.retain(|r| {
3284                *r == my_pk
3285                    || Some(r.to_hex()) == owner_hex_for_channels
3286                    || allowed.contains(&r.to_bytes())
3287            });
3288            let dropped = before.saturating_sub(recipients.len());
3289            if dropped > 0 {
3290                crate::log_info!(
3291                    "[Refound {}] purge: {} member(s) not on the retain list vended no key",
3292                    &cid_hex[..8.min(cid_hex.len())], dropped
3293                );
3294            }
3295        }
3296
3297        // Containment: vend no key to anyone who walked in DURING the raid.
3298        //
3299        // `removed` is a list computed at one instant; the recipient set above is a
3300        // LIVE fetch taken later, and re-taken on every retry. A bot joining in that
3301        // gap lands in the memberlist, misses the list, and is handed a working key
3302        // for the epoch meant to evict it. Testing the join TIME instead of matching a
3303        // frozen list is immune to when the fetch happens: a later read only surfaces
3304        // more late joiners, and each one fails the same test.
3305        //
3306        // Positive evidence only. No Join on the reachable guestbook means OLD (or
3307        // buried), never "new" — and an unreadable guestbook filters nobody, because
3308        // failing closed here would evict the whole community on one bad read.
3309        if joined_after_cut > 0 {
3310            match fetch_guestbook_events(transport, community, 0).await {
3311                Ok((events, _, _)) => {
3312                    let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
3313                    for ev in &events {
3314                        if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
3315                            let secs = at_ms / 1000;
3316                            first_seen.entry(*member).and_modify(|e| *e = (*e).min(secs)).or_insert(secs);
3317                        }
3318                    }
3319                    // A Join's `at_ms` is written by its author, so a raider can
3320                    // backdate under any window computed from it. What this client
3321                    // WATCHED arrive, they cannot forge — but only if we were already
3322                    // following before the window opened. A client that started
3323                    // watching later sees everyone as new, and trusting it would evict
3324                    // the whole community.
3325                    if guestbook_observed_since(&cid_hex).is_some_and(|since| since < joined_after_cut) {
3326                        if let Ok((local, _)) = crate::db::community::get_guestbook(&cid_hex) {
3327                            for ev in &local {
3328                                let (guestbook::GuestbookEntry::Join { member, .. }, Some(seen)) = (&ev.entry, ev.observed_at) else {
3329                                    continue;
3330                                };
3331                                // Incriminating only: an observation can move a member
3332                                // LATER (catching a backdated claim), never earlier.
3333                                first_seen.entry(*member).and_modify(|e| *e = (*e).max(seen)).or_insert(seen);
3334                            }
3335                        }
3336                    }
3337                    let before = recipients.len();
3338                    recipients.retain(|r| {
3339                        if *r == my_pk || Some(r.to_hex()) == owner_hex_for_channels {
3340                            return true;
3341                        }
3342                        // A moderator who joined moments before a raid is still a moderator.
3343                        if roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref()) {
3344                            return true;
3345                        }
3346                        first_seen.get(r).is_none_or(|joined| *joined < joined_after_cut)
3347                    });
3348                    let dropped = before.saturating_sub(recipients.len());
3349                    if dropped > 0 {
3350                        crate::log_info!(
3351                            "[Refound {}] containment: {} raid-window joiner(s) vended no key",
3352                            &cid_hex[..8.min(cid_hex.len())], dropped
3353                        );
3354                    }
3355                }
3356                Err(e) => crate::log_warn!(
3357                    "[Refound {}] guestbook unreadable — raid-window joiners NOT filtered, only the named cut is severed ({e})",
3358                    &cid_hex[..8.min(cid_hex.len())]
3359                ),
3360            }
3361        }
3362
3363        // STAFF COVERAGE GATE: a granted member who is not a recipient would be
3364        // stranded at this hop for good — they can only ever derive `held + 1`, so
3365        // no later rotation reaches them, while every other client still reads them
3366        // as staff. That is silent and permanent, and it happened: an admin holding
3367        // a valid grant was vended no key and only surfaced because a human noticed
3368        // the member list had shrunk.
3369        //
3370        // A grant is consensus-complete proof of membership — the same claim
3371        // `fold_members` makes when it counts granted members "even if their Join
3372        // aged out of the Guestbook entirely". So the recipients must cover them.
3373        // Refusing here matches the entity carry-forward directly above, which
3374        // ABORTS rather than publish a rotation that drops a floored head: better a
3375        // rotation that did not happen than one that quietly severs the staff.
3376        //
3377        // Only the CUT are exempt — removing someone is the one thing a rotation is
3378        // allowed to do to them.
3379        {
3380            // A banned member is not owed a key; every other grant-holder is.
3381            let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3382            let missing: Vec<String> = roster_for_channels
3383                .grants
3384                .iter()
3385                .filter(|g| !g.role_ids.is_empty())
3386                .filter_map(|g| PublicKey::from_hex(&g.member).ok())
3387                .filter(|pk| !removed_set.contains(&pk.to_bytes()))
3388                .filter(|pk| !banned.contains(&pk.to_hex()))
3389                .filter(|pk| !recipients.contains(pk))
3390                .map(|pk| pk.to_hex())
3391                .collect();
3392            if !missing.is_empty() {
3393                // The counts ride the message: which of the two folds lost them is
3394                // the whole diagnosis, and a refusal that does not say leaves the
3395                // next person guessing exactly as long as this one did.
3396                return Err(format!(
3397                    "re-founding aborted: {} granted member(s) would be vended no key and stranded at epoch {} — {}; \
3398                     recipients={} (network fold {}, local fold {}); no state published",
3399                    missing.len(),
3400                    prev_epoch.0,
3401                    missing.iter().map(|h| h[..8.min(h.len())].to_string()).collect::<Vec<_>>().join(", "),
3402                    recipients.len(),
3403                    net_len,
3404                    local_len
3405                ));
3406            }
3407        }
3408
3409        // Base rekey blobs, sealed under the PRIOR root: every member's carries the
3410        // new root + control_pk (104 bytes); a STAFF recipient's (CORD-04 §3)
3411        // appends the control_root itself (136) — the rotator is staff by
3412        // construction (a Refounding takes BAN). A stale-roster overshoot hands a
3413        // just-demoted member only flooding power until the next rotation, the
3414        // spec's accepted erosion; an undershoot self-heals via the Grant's
3415        // control_wrap re-delivery.
3416        let mut base_blobs = Vec::new();
3417        for r in &recipients {
3418            let staff = *r == my_pk || roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref());
3419            base_blobs.push(
3420                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))
3421                    .await
3422                    .map_err(|e| e.to_string())?,
3423            );
3424        }
3425        let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3426        let base_chunks =
3427            super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), sever)
3428                .await
3429                .map_err(|e| e.to_string())?;
3430
3431        // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
3432        // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
3433        //
3434        // Each private channel goes only to ITS entitled set, never the base recipient
3435        // list: a Refounding that re-broadcast every private key to every member would
3436        // undo the access lists on every rotation (CORD-03).
3437        let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
3438        let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
3439        for ch in &community.channels {
3440            let (Some(old_key), true) = (ch.key, ch.private) else { continue };
3441            let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
3442            let entitled: Vec<PublicKey> = recipients
3443                .iter()
3444                .copied()
3445                .filter(|r| {
3446                    *r == my_pk
3447                        || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
3448                })
3449                .collect();
3450            let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
3451            // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
3452            // retry-idempotency as the base root. The base-rekey signing above is a bunker
3453            // round-trip; re-check before this per-channel DB write straddles it.
3454            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)?;
3455            let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
3456            let mut ch_blobs = Vec::new();
3457            for r in &entitled {
3458                ch_blobs.push(
3459                    super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
3460                        .await
3461                        .map_err(|e| e.to_string())?,
3462                );
3463            }
3464            let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
3465            let ch_chunks = super::rekey::build_rekey_chunks(&signer, my_pk, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3466                .await
3467                .map_err(|e| e.to_string())?;
3468            channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
3469            channel_chunk_sets.push(ch_chunks);
3470        }
3471
3472        // COMMIT (durable publishes only — all fetching is done). Base rekey first
3473        // (delivers the new root), then channel rekeys, then the compacted control.
3474        for c in &base_chunks {
3475            transport.publish_durable(c, &community.relays).await?;
3476        }
3477        for set in &channel_chunk_sets {
3478            for c in set {
3479                transport.publish_durable(c, &community.relays).await?;
3480            }
3481        }
3482        for (_, wrap) in &carried {
3483            transport.publish_durable(wrap, &community.relays).await?;
3484        }
3485        // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
3486        // it; an omitted member heals by publishing their own Join).
3487        let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3488        let snap_id = crate::community::random_32();
3489        for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
3490            if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
3491                let _ = transport.publish(&wrap, &community.relays).await;
3492            }
3493        }
3494
3495        // COMMIT locally, only now that the new root + compacted plane are on relays.
3496        if crate::db::community::community_protocol(cid)?.is_none() {
3497            return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
3498        }
3499        // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
3500        // never leave the base root advanced while the channel keys lag (which would
3501        // re-derive the channel rekey address under the wrong root and orphan them).
3502        let mut updated = community.clone();
3503        updated.community_root = new_root;
3504        updated.root_epoch = new_epoch;
3505        updated.control_pk = Some(new_control_pk);
3506        updated.control_root = Some(new_control_root);
3507        for (id, key, ep) in &channel_updates {
3508            if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
3509                c.key = Some(*key);
3510                c.epoch = *ep;
3511            }
3512        }
3513        crate::db::community::save_community_v2(&updated)?;
3514        // Archive the new epoch key + confirm the monotonic base head (the root was already
3515        // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
3516        // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
3517        // compacted control on the next follow, so they self-heal.
3518        crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3519        for (h, _) in &carried {
3520            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3521        }
3522        // Re-subscribe NOW: the rotation changed every plane author, and the live sub
3523        // still carries the OLD epoch's set. Members adopt via the follow worker
3524        // (which refreshes); the REFOUNDER has no such path — without this, the very
3525        // client that performed the ban goes deaf to the new epoch (a rejoin lands on
3526        // the relays and never arrives live).
3527        if let Some(client) = crate::state::nostr_client() {
3528            super::realtime::refresh_subscription(&client).await;
3529        }
3530        // We minted this epoch, so our own snapshot is what seeds it (CORD-02 §5).
3531        // Recorded here so the local fold agrees with every follower's without
3532        // waiting to adopt our own rotation back off the wire.
3533        record_epoch_minter(&cid_hex, new_epoch.0, &my_pk.to_hex());
3534        if sever {
3535            // Severing: the mark persists FIRST (a crash between it and the revoke
3536            // must retry, not forget — B4), then my own links are tombstoned. Other
3537            // creators' links die when their clients adopt the marked rotation.
3538            add_sever_mark(&cid_hex, new_epoch.0, at_secs);
3539            for attempt in 0..3u8 {
3540                match sever_own_links(transport, &updated).await {
3541                    Ok(_) => break,
3542                    Err(e) if attempt == 2 => {
3543                        crate::log_warn!("v2: post-severing link revocation failed after retries ({e}); the sever mark retries it on the next follow");
3544                    }
3545                    Err(_) => continue,
3546                }
3547            }
3548        } else {
3549            // Refresh any live public links so their bundles carry the NEW root behind the
3550            // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
3551            // so retry a transient failure — a stranded link lands a new joiner on the dead
3552            // pre-refound epoch, and there's no other trigger to heal it before the next
3553            // refounding. A persistent failure is logged (refound already succeeded).
3554            for attempt in 0..3u8 {
3555                match refresh_public_links(transport, &updated).await {
3556                    Ok(()) => break,
3557                    Err(e) if attempt == 2 => {
3558                        crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
3559                    }
3560                    Err(_) => continue,
3561                }
3562            }
3563        }
3564        Ok(updated)
3565    })
3566    .await
3567}
3568
3569/// BIRTH refound (§migration Phase 1.4): roll a freshly-minted migration twin from epoch 0
3570/// to epoch 1 so it can carry an owner-signed Guestbook SNAPSHOT of the full v1 memberlist —
3571/// genesis (epoch 0) has no snapshot authority (`fold_members` gates on `root_epoch > 0`), so
3572/// this is the ONLY way to seed a roster every honest client folds. UNLIKE [`refound_community`]
3573/// the two sets are DECOUPLED:
3574///
3575/// - **Rekey recipients = {owner} ONLY.** Members do NOT get the epoch-1 root via birth blobs
3576///   — they get it from the migration carrier's `m` (sealed AFTER this returns). Keeping the
3577///   set at {owner} also keeps the rotation to a single chunk (blobs shard at 80 per event,
3578///   so a big memberlist would cost a stack of events delivering roots nobody uses).
3579/// - **Snapshot members = the EXPLICIT full v1 list** (`snapshot_members`, display/roster only,
3580///   no keys). Chunked at SNAPSHOT_CHUNK (400)/rumor, no cap — a 10k-member community seeds fine.
3581///
3582/// The SAFEST refound possible: the owner authored 100% of the control plane seconds ago and
3583/// holds every edition locally, so the fold-all-or-abort discipline is trivially met (a flaky
3584/// relay just fires the abort → the wizard retries). Returns the epoch-1 community.
3585pub async fn refound_at_birth<T: Transport + ?Sized>(
3586    transport: &T,
3587    community: &CommunityV2,
3588    snapshot_members: &[PublicKey],
3589) -> Result<CommunityV2, String> {
3590    crate::db::scoped(async move {
3591        let cid = community.id();
3592        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3593        // Death wins every race: a dissolved community never re-founds (CORD-02 §9, parity with
3594        // refound_community). A migration twin should never be dissolved mid-build, but fail-closed.
3595        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3596            return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
3597        }
3598        let signer = crate::signer::active_signer()?;
3599        let my_pk = me_pk()?;
3600        if my_pk != community.owner()? {
3601            return Err("only the owner can birth-refound the migration twin".to_string());
3602        }
3603        let lock = super::realtime::follow_lock(cid);
3604        let _guard = lock.lock().await;
3605        let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
3606        // RESUME IDEMPOTENCE: if the refound already committed locally (epoch 1) but crashed
3607        // before its ledger write, the wizard re-calls this. The epoch advance + compaction only
3608        // commit AFTER the snapshot published durably + verified back (below), so an epoch-1 twin
3609        // means the snapshot already landed and is readable — return it. A twin past epoch 1 is
3610        // unexpected (nothing else rotates a mid-migration twin).
3611        if community.root_epoch.0 == 1 {
3612            return Ok(community);
3613        }
3614        if community.root_epoch.0 != 0 {
3615            return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
3616        }
3617        let community = &community;
3618
3619        // Compact the epoch-0 control plane onto epoch 1: re-wrap the committed head of every
3620        // floored entity VERBATIM (inner owner/admin signatures survive). The owner holds every
3621        // edition locally (authored seconds ago), so this fold-all-or-abort is trivially met.
3622        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3623            .into_iter()
3624            .filter(|(_, f)| f.0 == community.root_epoch.0)
3625            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3626            .collect();
3627        let current_control = control::ControlPlane::of(community);
3628        let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3629        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3630        let mut oldest: Option<u64> = None;
3631        let mut until: Option<u64> = None;
3632        // Exhaustion, not coverage — see the sibling read in `refound_community`.
3633        let mut truncated = false;
3634        for page in 0..COMPACT_MAX_PAGES {
3635            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() };
3636            let wraps = transport.fetch(&query, &community.relays).await?;
3637            let mut fresh = 0usize;
3638            for w in &wraps {
3639                if !seen_wraps.insert(w.id) { continue; }
3640                fresh += 1;
3641                let at = w.created_at.as_secs();
3642                if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
3643                if let Ok(parsed) = current_control.open(w) {
3644                    opened.push(parsed);
3645                }
3646            }
3647            if fresh == 0 {
3648                truncated = wraps.len() >= FOLLOW_PAGE;
3649                break;
3650            }
3651            until = oldest;
3652            if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
3653        }
3654        if truncated {
3655            return Err(
3656                "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(),
3657            );
3658        }
3659
3660        let prev_epoch = community.root_epoch; // 0
3661        let new_epoch = Epoch(1);
3662        let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3663        let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3664        // The split's pair, minted beside the root exactly as in `refound_community`.
3665        let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3666        let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3667        let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3668        let at = now_ms();
3669        let at_secs = at / 1000;
3670
3671        use std::collections::BTreeMap;
3672        let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3673        for (i, (e, _)) in opened.iter().enumerate() {
3674            by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3675        }
3676        let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3677        for (floor_key, floor) in &floors {
3678            let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3679            let Some(head_idx) = head_idx else {
3680                return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
3681            };
3682            let (head_ed, head_os) = &opened[head_idx];
3683            let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3684            let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3685            carried.push((h, rewrapped));
3686        }
3687
3688        // Base rekey: the epoch-1 root to the OWNER ONLY (members key up via the
3689        // carrier's `m`). The owner is staff by definition — the 136-byte form.
3690        let base_blobs = vec![
3691            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))
3692                .await
3693                .map_err(|e| e.to_string())?,
3694        ];
3695        let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3696        let base_chunks =
3697            super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3698                .await
3699                .map_err(|e| e.to_string())?;
3700
3701        // COMMIT to the wire: base rekey (owner's new root), then the compacted control.
3702        for c in &base_chunks {
3703            transport.publish_durable(c, &community.relays).await?;
3704        }
3705        for (_, wrap) in &carried {
3706            transport.publish_durable(wrap, &community.relays).await?;
3707        }
3708        // The Guestbook SNAPSHOT — the WHOLE POINT of the birth refound, so publish it DURABLY
3709        // and FAIL the refound if any chunk doesn't land. Unlike `refound_community` (where
3710        // live members heal via their own Join if a chunk drops), a seeded-never-landed member
3711        // CANNOT heal — omitted → absent from `memberlist()` → excluded from every future rotation
3712        // → permanently stranded. So the snapshot is load-bearing, not best-effort. The publishes
3713        // precede the local commit, so a `?`-abort leaves epoch 0 and a retry re-runs idempotently
3714        // (mint_or_reuse gives the same epoch-1 root; snapshot chunks coalesce commutatively).
3715        let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3716        let snap_id = crate::community::random_32();
3717        let snapshot_wraps: Vec<Event> = {
3718            let mut out = Vec::new();
3719            for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
3720                let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
3721                    .await
3722                    .map_err(|e| format!("seal birth snapshot: {e}"))?;
3723                out.push(wrap);
3724            }
3725            out
3726        };
3727        for wrap in &snapshot_wraps {
3728            transport.publish_durable(wrap, &community.relays).await?;
3729        }
3730        // Verify-back (design §4 Phase 1.5): fetch the snapshot at the new epoch and confirm every
3731        // seeded member folds, before we commit locally. A relay that ACKed a durable publish but
3732        // won't serve it back (or a partial landing) aborts here with ZERO local state — the retry
3733        // re-publishes. A seed that is (legitimately) in the folded banlist is EXPECTED to be
3734        // absent from the memberlist (`memberlist` subtracts the banlist, so requiring a
3735        // banned seed to "fold" would wedge the retry forever) — so subtract the wire-folded
3736        // banlist from the expected set. The real caller never seeds a banned member, but the
3737        // arbitrary-`snapshot_members` API must not be able to wedge on one.
3738        let verify_view = {
3739            let mut v = community.clone();
3740            v.community_root = new_root;
3741            v.root_epoch = new_epoch;
3742            v.control_pk = Some(new_control_pk);
3743            v.control_root = Some(new_control_root);
3744            v
3745        };
3746        let expected: Vec<PublicKey> = {
3747            let banlist = fetch_authority(transport, &verify_view).await.banned;
3748            snapshot_members.iter().copied()
3749                .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
3750                .collect()
3751        };
3752        if !expected.is_empty() {
3753            let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
3754            let missing = expected.iter().filter(|m| !folded.contains(m)).count();
3755            if missing > 0 {
3756                return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
3757            }
3758        }
3759
3760        // COMMIT locally, only now that the new root + compacted plane + snapshot are on relays.
3761        if crate::db::community::community_protocol(cid)?.is_none() {
3762            return Ok(community.clone());
3763        }
3764        let mut updated = community.clone();
3765        updated.community_root = new_root;
3766        updated.root_epoch = new_epoch;
3767        updated.control_pk = Some(new_control_pk);
3768        updated.control_root = Some(new_control_root);
3769        crate::db::community::save_community_v2(&updated)?;
3770        crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3771        for (h, _) in &carried {
3772            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3773        }
3774        Ok(updated)
3775    })
3776    .await
3777}
3778
3779/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
3780/// already archived from a prior (aborted) attempt — so a retried Refounding re-
3781/// delivers the SAME key at the same epoch/address instead of double-minting two roots
3782/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
3783/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
3784/// sentinel for a base rotation, else the channel_id hex.
3785fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
3786    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
3787        return Ok(existing);
3788    }
3789    let fresh = crate::community::random_32();
3790    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
3791    Ok(fresh)
3792}
3793
3794// ── The Community List (kind 33302, CORD-02 §8) ──────────────────────────────
3795
3796/// This community's MEMBERSHIP subset for the Community List (CORD-02 §8): never the
3797/// icon (a rehydrating device folds it from the Control Plane), never the link
3798/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
3799fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
3800    let hex = crate::simd::hex::bytes_to_hex_32;
3801    let channels = community
3802        .channels
3803        .iter()
3804        .filter(|c| c.private)
3805        // Keyed channels ONLY. A keyless entry is readable by this build but is
3806        // rejected outright by shipped ones (their `key` is a required String),
3807        // so emitting one would strand every older client on a stale list.
3808        .filter_map(|c| {
3809            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() })
3810        })
3811        .collect();
3812    super::list::JoinMaterial {
3813        community_id: hex(&community.identity.community_id.0),
3814        owner: hex(&community.identity.owner_xonly),
3815        owner_salt: hex(&community.identity.owner_salt),
3816        community_root: hex(&community.community_root),
3817        root_epoch: community.root_epoch.0,
3818        control_pk: community.control_pk.map(|p| p.to_hex()),
3819        // The list carries every private key its holder has (CORD-02 §8) — a
3820        // staffer's own devices must be able to write. Same trust class as the
3821        // community_root beside it: NIP-44-encrypted to self.
3822        control_root: community.control_root.map(|r| hex(&r)),
3823        channels,
3824        relays: community.relays.clone(),
3825        name: community.name.clone(),
3826        extra: Default::default(),
3827    }
3828}
3829
3830/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
3831/// (the material IS the membership subset of a bundle). The owner root is still
3832/// verified over the network before the community is trusted (accept_bundle).
3833fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
3834    // A keyless listing records that the channel EXISTS, not a grant — there is
3835    // nothing to seat, and it keys up when access is granted.
3836    let channels = jm
3837        .channels
3838        .iter()
3839        .filter_map(|c| {
3840            c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
3841        })
3842        .collect();
3843    CommunityInvite {
3844        community_id: jm.community_id.clone(),
3845        owner: jm.owner.clone(),
3846        owner_salt: jm.owner_salt.clone(),
3847        community_root: jm.community_root.clone(),
3848        root_epoch: jm.root_epoch,
3849        control_pk: jm.control_pk.clone(),
3850        channels,
3851        relays: jm.relays.clone(),
3852        name: jm.name.clone(),
3853        icon: None,
3854        expires_at: None,
3855        creator_npub: None,
3856        label: None,
3857        extra: Default::default(),
3858    }
3859}
3860
3861/// The account's OWN relays — where an account-level list belongs. Published to a
3862/// community's relays instead, the list stops being readable the moment that
3863/// community is left, which is exactly when its tombstone has to be found.
3864/// Empty without a client (tests), where the caller falls back to the held set.
3865async fn own_list_relays() -> Vec<String> {
3866    let Some(client) = crate::state::nostr_client() else { return Vec::new() };
3867    client
3868        .relays()
3869        .await
3870        .iter()
3871        .filter(|(_, r)| r.capabilities().load().can_write())
3872        .map(|(url, _)| url.to_string())
3873        .collect()
3874}
3875
3876/// Read the list from everywhere a copy could be: our own relays, the stock CORD
3877/// set (where it lands when ours refuse the kind), and the held communities'
3878/// (where every list published before the move still lives). Missing one of these
3879/// reads a stale copy and republishes it over a newer tombstone.
3880async fn list_read_relays(extra: &[String]) -> Vec<String> {
3881    let mut set = own_list_relays().await;
3882    set.extend(invite::stock_relays());
3883    set.extend(held_v2_relays());
3884    set.extend(extra.iter().cloned());
3885    set.sort();
3886    set.dedup();
3887    set
3888}
3889
3890/// What a fragment fetch found: the unioned list, and each index's `created_at`
3891/// so the next write to that fragment can exceed it.
3892pub struct FragSet {
3893    pub list: super::list::CommunityList,
3894    pub created_at: std::collections::BTreeMap<usize, u64>,
3895    /// `frags` as declared by the newest fragment seen.
3896    pub declared: usize,
3897    /// The winning parsed fragment per index — what the relays currently hold,
3898    /// so a rewrite can skip publishing byte-identical fragments.
3899    pub read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList>,
3900}
3901
3902impl FragSet {
3903    /// Coverage, not agreement: we hold every index below `declared`, whatever
3904    /// their individual ages. A short read is read-only, never a write.
3905    pub fn is_complete(&self) -> bool {
3906        (0..self.declared).all(|i| self.created_at.contains_key(&i))
3907    }
3908}
3909
3910/// Fetch the account's 33302 fragments and union them. Newest wins PER INDEX —
3911/// each fragment is its own addressable coordinate, so they age independently.
3912async fn fetch_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<FragSet>, String> {
3913    let signer = crate::signer::active_signer()?;
3914    let my_pk = me_pk()?;
3915    let query = Query {
3916        kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3917        authors: vec![my_pk.to_hex()],
3918        limit: Some(64),
3919        ..Default::default()
3920    };
3921    let events = transport.fetch(&query, relays).await?;
3922    if events.is_empty() {
3923        return Ok(None);
3924    }
3925    let mut newest: std::collections::BTreeMap<usize, (u64, [u8; 32], super::list_frag::FragList)> = Default::default();
3926    let mut undecryptable = 0usize;
3927    for e in events {
3928        let at = e.created_at.as_secs();
3929        let id = *e.id.as_bytes();
3930        match super::list_frag::parse_fragment_event(&signer, my_pk, &e).await {
3931            Ok((index, frag)) => {
3932                // Mirror relay resolution (CORD-02 §8): newest created_at holds the
3933                // coordinate, an age tie falls to the LOWEST event id.
3934                let wins = match newest.get(&index) {
3935                    None => true,
3936                    Some((prev_at, prev_id, _)) => at > *prev_at || (at == *prev_at && id < *prev_id),
3937                };
3938                if wins {
3939                    newest.insert(index, (at, id, frag));
3940                }
3941            }
3942            Err(_) => undecryptable += 1,
3943        }
3944    }
3945    if newest.is_empty() {
3946        crate::log_warn!("[CommunityList] {} fragment(s) fetched, none readable — treating as no news", undecryptable);
3947        return Ok(None);
3948    }
3949    // The newest fragment governs `frags`; an age tie resolves to the LARGER count
3950    // (CORD-02 §8) — too large reads an index that turns out empty, too small sends
3951    // live fragments out of range and dormant.
3952    let declared = newest
3953        .values()
3954        .max_by_key(|(at, _, f)| (*at, f.frags))
3955        .map(|(_, _, f)| f.frags)
3956        .unwrap_or(1)
3957        .max(1);
3958    let read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList> =
3959        newest.iter().map(|(i, (_, _, f))| (*i, f.clone())).collect();
3960    let frags: Vec<_> = read_frags.values().cloned().collect();
3961    let set = FragSet {
3962        list: super::list_frag::defragment(&frags),
3963        created_at: newest.iter().map(|(i, (at, _, _))| (*i, *at)).collect(),
3964        declared,
3965        read_frags,
3966    };
3967    if !set.is_complete() {
3968        crate::log_warn!(
3969            "[CommunityList] INCOMPLETE: hold {} of {} fragment(s) — reading, refusing to write",
3970            set.created_at.len(),
3971            declared
3972        );
3973    }
3974    Ok(Some(set))
3975}
3976
3977/// Publish a list as fragments. Each fragment's `created_at` must exceed that
3978/// fragment's own previous value — relays resolve an addressable event on
3979/// `created_at` alone and break a tie on the lowest event id, so a same-second
3980/// rewrite can silently discard the newer content.
3981async fn publish_fragments<T: Transport + ?Sized>(
3982    transport: &T,
3983    list: &super::list::CommunityList,
3984    prev: &std::collections::BTreeMap<usize, u64>,
3985    read: &std::collections::BTreeMap<usize, super::list_frag::FragList>,
3986    own: &[String],
3987) -> Result<usize, String> {
3988    let signer = crate::signer::active_signer()?;
3989    let my_pk = me_pk()?;
3990    let frags = super::list_frag::fragment(list);
3991    let now = now_ms() / 1000;
3992    // A fragment serializing to the bytes we just read is already on the relay:
3993    // republishing it only churns created_at — and hands a same-second sibling
3994    // write a lowest-id coin-flip it didn't need to enter.
3995    let unchanged = |index: usize, frag: &super::list_frag::FragList| -> bool {
3996        let Some(old) = read.get(&index) else { return false };
3997        matches!(
3998            (serde_json::to_string(old), serde_json::to_string(frag)),
3999            (Ok(a), Ok(b)) if a == b
4000        )
4001    };
4002    // Persisted, not level-gated: a List write that silently never lands is the failure
4003    // mode this whole format exists to end, and it is only ever diagnosed after the fact.
4004    crate::log_net_info!(
4005        "[CommunityList] publishing {} fragment(s): {} live entries ({} retired), {} tombstones",
4006        frags.len(),
4007        frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4008        list.entries.len() - frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4009        frags.iter().map(|f| f.tombstones.len()).sum::<usize>(),
4010    );
4011    let mut skipped = 0usize;
4012    for (index, frag) in frags.iter().enumerate() {
4013        if unchanged(index, frag) {
4014            skipped += 1;
4015            continue;
4016        }
4017        let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4018        let event = super::list_frag::build_fragment_event(&signer, my_pk, frag, index, created_at).await?;
4019        publish_list_event(transport, &event, own).await?;
4020    }
4021    // A shrunk set leaves the fragments above it in place; empty them so a later
4022    // growth into that index cannot re-read stale memberships.
4023    for index in frags.len()..prev.len() {
4024        let empty = super::list_frag::FragList {
4025            frags: frags.len(),
4026            entries: vec![],
4027            tombstones: vec![],
4028            extra: Default::default(),
4029        };
4030        if unchanged(index, &empty) {
4031            skipped += 1;
4032            continue;
4033        }
4034        let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4035        let event = super::list_frag::build_fragment_event(&signer, my_pk, &empty, index, created_at).await?;
4036        publish_list_event(transport, &event, own).await?;
4037    }
4038    if skipped > 0 {
4039        crate::log_net_info!("[CommunityList] {} fragment(s) skipped — byte-identical to the relay copy", skipped);
4040    }
4041    Ok(frags.len())
4042}
4043
4044/// Publish an account-level list, falling back to the stock CORD relays when our
4045/// own reject it — 33302 is a custom kind, and a relay with a kind whitelist drops
4046/// it silently. The list is the vault: it has to land somewhere every client reads.
4047async fn publish_list_event<T: Transport + ?Sized>(transport: &T, event: &Event, own: &[String]) -> Result<(), String> {
4048    if !own.is_empty() {
4049        match transport.publish(event, own).await {
4050            Ok(()) => return Ok(()),
4051            Err(e) => crate::log_warn!("[CommunityList] own relays refused the list ({}) — falling back to the stock set", e),
4052        }
4053    }
4054    transport.publish(event, &invite::stock_relays()).await
4055}
4056
4057/// The union of every held v2 community's relays — a fallback write target, and
4058/// where every list published before the move to our own relays still lives.
4059fn held_v2_relays() -> Vec<String> {
4060    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4061    if let Ok(ids) = crate::db::community::list_community_ids() {
4062        for id in ids {
4063            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4064                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
4065                    set.extend(c.relays);
4066                }
4067            }
4068        }
4069    }
4070    set.into_iter().collect()
4071}
4072
4073/// Rebuild this account's Community List from its held v2 communities, MERGE with the remote
4074/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
4075/// `just_joined` is the community THIS call is recording a create/join for — the
4076/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
4077/// (a deliberate re-join resurrects). Every OTHER held community that the remote
4078/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
4079/// because we joined something else — the W1 resurrection hole). Idempotent;
4080/// best-effort — a list-publish failure never fails the membership change itself.
4081/// Returns `Ok(true)` when the list was PUBLISHED, `Ok(false)` when the attempt was
4082/// skipped without failing the caller (a failed remote fetch — see below). Callers that
4083/// need the membership to actually land use [`republish_community_list_durable`].
4084pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
4085    crate::db::scoped(async move {
4086        let held = held_v2_relays();
4087        if held.is_empty() {
4088            return Ok(false); // nothing held → nothing to sync
4089        }
4090        let relays = list_read_relays(&[]).await;
4091        let own = own_list_relays().await;
4092        // Written to our own relays, read from both: the copy has to outlive any
4093        // one membership. Falls back to the held set when we have no relays of
4094        // our own to write to.
4095        let write_relays = if own.is_empty() { held.clone() } else { own };
4096        // A FAILED remote fetch must not drive this replaceable-event write: publishing
4097        // a list built without the remote seeds would drop older-epoch backfill anchors
4098        // and re-stamp add-times (the W2 seed-regression + a resurrection window).
4099        let (remote, prev_created, read_frags) = match fetch_fragments(transport, &relays).await {
4100            // No fragments yet: this is the first write under §8, and local state
4101            // is the source. Nothing to merge, nothing to lose.
4102            Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4103            Ok(Some(set)) if !set.is_complete() => {
4104                crate::log_net_fail!(
4105                    "[CommunityList] republish SKIPPED — hold {} of {} fragments; rewriting a set we haven't fully read would drop the memberships in the ones we're missing",
4106                    set.created_at.len(),
4107                    set.declared
4108                );
4109                return Ok(false);
4110            }
4111            Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4112            Err(e) => {
4113                // SILENT-SKIP HAZARD: bailing is correct (publishing a list built without the
4114                // remote seeds drops backfill anchors), but the membership this call was meant
4115                // to record is now simply unrecorded. A join that lands here leaves a community
4116                // held locally with no list entry — and if it also carries an older tombstone,
4117                // nothing ever out-ranks it again. Say so loudly; `Ok(())` keeps it non-fatal.
4118                crate::log_warn!(
4119                    "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
4120                    e,
4121                    just_joined
4122                        .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
4123                        .unwrap_or_default()
4124                );
4125                return Ok(false);
4126            }
4127        };
4128        let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
4129        let now = now_ms();
4130        let mut local = super::list::CommunityList::default();
4131        for id in crate::db::community::list_community_ids()? {
4132            if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4133                continue;
4134            }
4135            let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
4136            let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
4137            let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
4138            // A held community the remote has tombstoned (a sibling device left it) that
4139            // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
4140            // different community would silently undo the leave everywhere.
4141            //
4142            // UNLESS our hold POST-DATES the removal. A rejoin whose membership never
4143            // reached the list (this publish is best-effort — a failed remote fetch
4144            // silently skips it) leaves a tombstone with no entry, and nothing can ever
4145            // out-rank it again: every boot the list sync reads "removed", tears the
4146            // community down, the rejoin re-adds it, and it loops forever. Our own hold
4147            // is first-hand evidence of membership, so let it settle the tie by the same
4148            // add-vs-remove rule the list already uses everywhere else.
4149            let tombstoned_at = remote
4150                .tombstones
4151                .iter()
4152                .find(|t| t.community_id == cid_hex)
4153                .map(|t| t.removed_at)
4154                .unwrap_or(0);
4155            let held_since = c.created_at_ms;
4156            if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
4157                crate::log_warn!(
4158                    "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
4159                    &cid_hex[..8], tombstoned_at, held_since
4160                );
4161                continue;
4162            }
4163            // Keep an already-live entry's add time (no churn); the joined community (or a
4164            // genuinely-new one) stamps `now` so a re-join beats a stale tombstone. A hold
4165            // that outlived a tombstone re-asserts itself at its own join time, which is
4166            // already newer than the removal.
4167            let added_at = if remote.is_live(&cid_hex) && !is_join {
4168                remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
4169            } else if !is_join && tombstoned_at > 0 {
4170                held_since
4171            } else {
4172                now
4173            };
4174            let jm = join_material(&c);
4175            local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
4176        }
4177        let merged = remote.merge(&local);
4178        // No whole-list cap: fragmentation is what keeps each event publishable,
4179        // and the count limit is gone (CORD-02 §8).
4180        match publish_fragments(transport, &merged, &prev_created, &read_frags, &write_relays).await {
4181            Ok(_) => {}
4182            Err(e) => {
4183                crate::log_net_fail!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
4184                return Err(e);
4185            }
4186        }
4187        Ok(true)
4188    })
4189    .await
4190}
4191
4192/// Retry budget for [`republish_community_list_durable`]. An unrecorded membership is
4193/// invisible to the user and self-heals only on their NEXT join, so ride out a relay
4194/// blip rather than a single shot. Bounded: a permanently dead relay set gives up
4195/// instead of spinning.
4196const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
4197
4198/// Record a membership across devices DURABLY: retry in the background until the list
4199/// actually lands.
4200///
4201/// [`republish_community_list`] must never fail a join, and it deliberately publishes
4202/// NOTHING when the remote fetch fails (a list built without the remote seeds would drop
4203/// other devices' entries). One shot at that means a relay blip during a join leaves the
4204/// membership unrecorded until the user happens to join something else — and if a stale
4205/// tombstone out-ranks it, the community is stranded until a manual leave+rejoin.
4206///
4207/// Non-blocking. Skipped entirely without a live client (headless/unit tests drive the
4208/// generic fn directly). Bound to its account, so a swap mid-backoff leaves it
4209/// publishing A's list from A's client rather than from B's.
4210pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
4211    if crate::state::nostr_client().is_none() {
4212        return;
4213    }
4214    crate::db::spawn_bound(async move {
4215        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4216            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4217            match republish_community_list(&transport, just_joined.as_ref()).await {
4218                Ok(true) => {
4219                    if attempt > 0 {
4220                        crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
4221                    }
4222                    return;
4223                }
4224                Ok(false) => {} // skipped (remote fetch failed) — already logged; retry
4225                Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
4226            }
4227            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
4228        }
4229        crate::log_warn!(
4230            "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
4231            LIST_REPUBLISH_BACKOFF_SECS.len()
4232        );
4233    });
4234}
4235
4236/// Record a permanent leave tombstone for `community_id` in the List. Written to
4237/// our OWN relays: the departing community's are about to stop being read at all,
4238/// so a tombstone left only there is one no later sync can ever fetch — the
4239/// community then rejoins itself from a stale copy on the next boot.
4240async fn tombstone_community_list<T: Transport + ?Sized>(
4241    transport: &T,
4242    community_id: &crate::community::CommunityId,
4243    relays: &[String],
4244    removed_at: u64,
4245) -> Result<(), String> {
4246    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4247    let read_relays = list_read_relays(relays).await;
4248    let own = own_list_relays().await;
4249    let write_relays = if own.is_empty() { relays.to_vec() } else { own };
4250    // A failed fetch here would drop other communities' entries (only the
4251    // tombstone would survive); preserve them by bailing — the leave re-records
4252    // on the next attempt, and the local teardown already happened.
4253    let (mut doc, prev_created, read_frags) = match fetch_fragments(transport, &read_relays).await {
4254        Ok(Some(set)) if !set.is_complete() => {
4255            return Err(format!("hold {} of {} fragments — refusing to rewrite a set we haven't fully read", set.created_at.len(), set.declared));
4256        }
4257        Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4258        Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4259        Err(e) => return Err(e),
4260    };
4261    doc.tombstones.retain(|t| t.community_id != cid_hex);
4262    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at, extra: Default::default() });
4263    // No size gate on the way out: a tombstone strictly shrinks the live set, and
4264    // a guard that blocks the only operation able to restore compliance is a
4265    // deadlock, not a guard (CORD-02 §8).
4266    publish_fragments(transport, &doc, &prev_created, &read_frags, &write_relays).await.map(|_| ())
4267}
4268
4269/// Retry a leave tombstone until it lands, on the same budget a join gets. A leave
4270/// that fails to record is the worse of the two: the local hold is already gone, so
4271/// this device shows the community left while every other one still holds it, and
4272/// nothing re-records it until some unrelated edit happens to carry it.
4273///
4274/// `removed_at` is the caller's, not `now` — re-stamping it here would let a retry
4275/// that fires after a genuine rejoin bury that rejoin.
4276pub fn tombstone_community_list_durable(community_id: crate::community::CommunityId, relays: Vec<String>, removed_at: u64) {
4277    if crate::state::nostr_client().is_none() {
4278        return;
4279    }
4280    crate::db::spawn_bound(async move {
4281        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4282            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
4283            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4284            match tombstone_community_list(&transport, &community_id, &relays, removed_at).await {
4285                Ok(()) => {
4286                    crate::log_net_info!(
4287                        "[CommunityList] leave tombstone for {} landed on retry #{}",
4288                        &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4289                        attempt + 1
4290                    );
4291                    return;
4292                }
4293                Err(e) => crate::log_warn!("[CommunityList] leave tombstone retry #{} failed: {}", attempt + 1, e),
4294            }
4295        }
4296        crate::log_net_fail!(
4297            "[CommunityList] leave tombstone for {} NEVER landed after {} attempts — it will come back on another device",
4298            &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4299            LIST_REPUBLISH_BACKOFF_SECS.len()
4300        );
4301    });
4302}
4303
4304/// Sync memberships from the Community List across devices: fetch this account's list from
4305/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
4306/// a fresh device), and JOIN every live entry not already held — reconstructing the
4307/// community from its join material and re-verifying the owner root. Returns the
4308/// newly-rehydrated communities (so the caller can subscribe + notify).
4309/// What one Community-List sync changed locally.
4310pub struct ListSyncOutcome {
4311    /// Communities newly adopted from the list (already persisted + chat-registered).
4312    pub joined: Vec<CommunityV2>,
4313    /// Communities a sibling device LEFT, as `(community_id_hex, channel_id_hexes)`.
4314    ///
4315    /// The rows are already gone here, so the ids are captured BEFORE deletion: the caller
4316    /// still has to finish the local teardown (chat rows, STATE, the live subscription),
4317    /// and it can't look them up afterwards. Deleting the community while leaving its chat
4318    /// row behind is what produces a ghost "0 Members" room pointing at nothing.
4319    pub removed: Vec<(String, Vec<String>)>,
4320}
4321
4322/// Marks that this account has written the fragmented list at least once.
4323const LIST_SEEDED_KEY: &str = "community_list_frag_seeded";
4324
4325/// The join refusal for a VERIFIED dissolution — an owner-signed, identity-bound
4326/// tombstone (§9: no un-dissolve). A sentinel because the accept boundary matches
4327/// on it: this is the one join failure that is a verdict rather than a maybe, so
4328/// the parked invite is retired instead of kept for a retry that can never succeed.
4329pub const ERR_DISSOLVED: &str = "this community has been dissolved";
4330
4331/// Retire a parked invite whose community is provably dead: drop the local row and
4332/// tombstone the §8 List so every sibling device purges its copy on the next sync.
4333/// ONLY for the dissolved verdict — ambiguous could-not-verify failures stay parked.
4334pub async fn retire_dead_invite<T: Transport + ?Sized>(transport: &T, community_id_hex: &str, relays: &[String]) {
4335    let _ = crate::db::community::delete_pending_invite(community_id_hex);
4336    if let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(community_id_hex) {
4337        let id = crate::community::CommunityId(cid);
4338        let at = now_ms();
4339        if tombstone_community_list(transport, &id, &relays.to_vec(), at).await.is_err() {
4340            // The row is already gone locally; make sure the suppression still
4341            // reaches sibling devices once the relays cooperate.
4342            tombstone_community_list_durable(id, relays.to_vec(), at);
4343        }
4344    }
4345    crate::emit_event("community_invites_purged", &serde_json::json!({}));
4346}
4347
4348/// Every relay answered EOSE with zero fragments — the only reading of "empty"
4349/// strong enough to seed over. The aggregate fetch that precedes this cannot
4350/// distinguish "no fragments" from "the relay holding them never answered";
4351/// asked one at a time, an error is a FAILED read and a failed read never seeds.
4352async fn confirmed_no_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> bool {
4353    let Ok(my_pk) = me_pk() else { return false };
4354    if relays.is_empty() {
4355        return false;
4356    }
4357    let query = Query {
4358        kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
4359        authors: vec![my_pk.to_hex()],
4360        limit: Some(1),
4361        ..Default::default()
4362    };
4363    for relay in relays {
4364        match transport.fetch(&query, std::slice::from_ref(relay)).await {
4365            Ok(events) if events.is_empty() => {}
4366            // Found one (the aggregate read raced a sibling's write) or the relay
4367            // didn't answer — either way this account is not confirmed empty.
4368            _ => return false,
4369        }
4370    }
4371    true
4372}
4373
4374/// First write of the §8 List for an account that has none: an account upgrading
4375/// from the retired single-event list arrives here with memberships that exist
4376/// only in local state, and nothing else in the stack publishes without a
4377/// membership change to record. Latched once it lands, so a boot-load read that
4378/// comes back empty can never republish local state over a sibling's tombstones.
4379async fn seed_community_list<T: Transport + ?Sized>(transport: &T) {
4380    if crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).ok().flatten().is_some() {
4381        return;
4382    }
4383    if held_v2_relays().is_empty() {
4384        return;
4385    }
4386    // Seeding over a read that merely FAILED publishes fragment 0 over a sibling
4387    // device's tombstones at a fresh created_at — confirm the emptiness per relay.
4388    let own = own_list_relays().await;
4389    let confirm = if own.is_empty() { held_v2_relays() } else { own };
4390    if !confirmed_no_fragments(transport, &confirm).await {
4391        crate::log_warn!("[CommunityList] seed DEFERRED — the empty read is unconfirmed; retrying on a later boot");
4392        return;
4393    }
4394    crate::log_net_info!("[CommunityList] no fragments held anywhere (confirmed per relay) — seeding from local state");
4395    match republish_community_list(transport, None).await {
4396        Ok(true) => {
4397            let _ = crate::db::settings::set_sql_setting(LIST_SEEDED_KEY.to_string(), "1".to_string());
4398        }
4399        Ok(false) => {}
4400        Err(e) => crate::log_net_fail!("[CommunityList] seed failed: {e}"),
4401    }
4402}
4403
4404pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
4405    crate::db::scoped(async move {
4406        let relays = list_read_relays(bootstrap_relays).await;
4407        if relays.is_empty() {
4408            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4409        }
4410        // A cross-device sync that finds nothing is indistinguishable from one that
4411        // never ran, so every exit says why — this path is only ever debugged after
4412        // the fact, from a user's log.
4413        let list = match fetch_fragments(transport, &relays).await {
4414            Ok(Some(set)) => {
4415                crate::log_net_info!(
4416                    "[CommunityList] fetched: {} of {} fragment(s), {} entries, {} tombstones, across {} relays",
4417                    set.created_at.len(),
4418                    set.declared,
4419                    set.list.entries.len(),
4420                    set.list.tombstones.len(),
4421                    relays.len()
4422                );
4423                set.list
4424            }
4425            Ok(None) => {
4426                // Transient by nature: boot runs many concurrent passes and a relay that
4427                // times out under that load returns nothing. Only persistent absence
4428                // matters, and that shows up as "adopted nothing" anyway.
4429                crate::log_debug!("[CommunityList] no fragments across {} relays", relays.len());
4430                seed_community_list(transport).await;
4431                return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4432            }
4433            Err(e) => {
4434                crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
4435                return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4436            }
4437        };
4438        // Receive-side teardown (the counterpart to the republish tombstone guard):
4439        // a community this device still holds but the synced list shows TOMBSTONED (a
4440        // sibling device left it) and NOT live gets torn down here, so a leave on one
4441        // device propagates to the others. A re-join would have re-added it live
4442        // (beating the tombstone), so is_live short-circuits the honest case.
4443        let mut removed: Vec<(String, Vec<String>)> = Vec::new();
4444        for t in &list.tombstones {
4445            if list.is_live(&t.community_id) {
4446                continue;
4447            }
4448            let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
4449            let id = crate::community::CommunityId(cid);
4450            let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
4451                continue; // not held — nothing to tear down
4452            };
4453            // `is_live` above assumes a rejoin re-added an entry, but recording that entry is
4454            // best-effort: a relay blip at join time leaves the tombstone unopposed forever, and
4455            // this would then delete the community on every sync. So let the LOCAL hold break the
4456            // tie too — a hold created after the removal IS the rejoin, whether or not its entry
4457            // ever reached the list. Same rule the v1 sweep uses.
4458            if held.created_at_ms > t.removed_at {
4459                crate::log_warn!(
4460                    "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
4461                    &t.community_id[..8], t.removed_at, held.created_at_ms
4462                );
4463                continue;
4464            }
4465            let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
4466            let _ = crate::db::community::delete_community(&t.community_id);
4467            removed.push((t.community_id.clone(), channel_ids));
4468        }
4469        // A tombstone also retires any PARKED invite it post-dates — a decline or a
4470        // dissolution-retire made on another device. Supersession, not a bare id
4471        // match: only a removal NEWER than the invite's arrival is a verdict on it;
4472        // an older tombstone is a past leave the re-invite already superseded
4473        // (received_at is seconds, removed_at is ms).
4474        let mut invites_purged = 0usize;
4475        for t in &list.tombstones {
4476            if list.is_live(&t.community_id) {
4477                continue;
4478            }
4479            let Ok(Some(received_at)) = crate::db::community::pending_invite_received_at(&t.community_id) else { continue };
4480            if (received_at.max(0) as u64).saturating_mul(1000) <= t.removed_at
4481                && crate::db::community::delete_pending_invite(&t.community_id).is_ok()
4482            {
4483                invites_purged += 1;
4484            }
4485        }
4486        if invites_purged > 0 {
4487            crate::emit_event("community_invites_purged", &serde_json::json!({}));
4488        }
4489        let mut joined = Vec::new();
4490        for entry in list.live_entries() {
4491            let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
4492            // Held under ANY protocol, not just v2. A protocol-scoped check re-adopts an
4493            // id we already hold as v1: verification correctly fails (there is no v2
4494            // community at that coordinate), and the entry is retried on EVERY sync pass
4495            // forever — a permanent warning flood plus a wasted multi-relay walk each time.
4496            if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
4497                continue; // already held
4498            }
4499            // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
4500            // and seeds floors. NO Guestbook Join: this device is receiving keys the
4501            // account already holds elsewhere — the membership was announced when it
4502            // actually joined, and a key sync is not a membership event.
4503            let bundle = material_to_invite(&entry.current);
4504            match accept_bundle(transport, &bundle, None, false).await {
4505                Ok(community) => {
4506                    // The staff write secret never rides a bundle shape — adopt it
4507                    // from the list entry directly, and only when it derives to the
4508                    // control_pk held for exactly this epoch (CORD-02 §5/§8). A
4509                    // stale secret fails closed to read-only; a 136-byte base blob
4510                    // or the Grant's control_wrap re-delivers on the walk forward.
4511                    if let (Some(pk), Some(root_hex)) = (community.control_pk, entry.current.control_root.as_deref()) {
4512                        if let Some(root) = crate::simd::hex::hex_to_bytes_32_checked(root_hex) {
4513                            if super::derive::control_signer_group_key(&root, community.id(), community.root_epoch).pk() == pk
4514
4515                            {
4516                                let mut held = community.clone();
4517                                held.control_root = Some(root);
4518                                let _ = crate::db::community::save_community_v2(&held);
4519                            }
4520                        }
4521                    }
4522                    joined.push(community);
4523                }
4524                // A listed-but-unadoptable entry is the failure mode that reads as
4525                // "cross-device sync is broken": the community never appears and any
4526                // parked invite for it is never retired.
4527                Err(e) => crate::log_net_fail!(
4528                    "[CommunityList] {} is listed but adoption failed: {e}",
4529                    &entry.community_id[..entry.community_id.len().min(8)]
4530                ),
4531            }
4532        }
4533        Ok(ListSyncOutcome { joined, removed })
4534    })
4535    .await
4536}
4537
4538// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
4539
4540/// Publish one control edition (a role, grant, banlist, community-metadata, or
4541/// channel-metadata edit) at the next version for its entity, chaining `prev` from
4542/// our held head, and advance our local floor. Authority is enforced by every
4543/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
4544/// requires only a valid local signer; a well-behaved client checks its own rank
4545/// first, but a reader drops an unauthorized edition regardless.
4546/// This actor's authority citation for a control edition (CORD-04 §5): the head
4547/// of their OWN Grant entity, pinned by coordinate + version + edition hash.
4548///
4549/// A SYNC FLOOR, not a verdict — a verifier refuses to act until it has synced
4550/// at least this Grant, then resolves rank against its CURRENT roster, so a
4551/// demoted admin is never grandfathered by an old-but-once-valid citation.
4552///
4553/// `None` for the owner (supreme, rank comes from the community id) and `None`
4554/// when no Grant head is held — an actor who cannot cite has no rank to claim,
4555/// and the edition is dropped by a conforming reader either way.
4556/// The verify half of [`my_authority_citation`] (CORD-04 §5): does the actor's
4557/// cited Grant prove authority we have actually SYNCED? The owner is supreme and
4558/// cites nothing. A non-owner MUST cite, and we must hold that Grant at ≥ the
4559/// cited version with the cited hash at the tip — else fail closed, because
4560/// honoring an action whose authority we can't confirm is exactly how a demoted
4561/// moderator keeps moderating.
4562///
4563/// Completeness only: the permission + outrank is the separate roster check, so a
4564/// since-demoted actor is refused there (refuse-superseded). An action citing a
4565/// version we haven't synced parks and is re-judged on the next roster sync — the
4566/// sync path can't escalate to a blocking fetch.
4567pub(super) fn citation_is_synced(
4568    cid_hex: &str,
4569    owner_hex: &str,
4570    actor_hex: &str,
4571    citation: Option<&crate::community::edition::AuthorityCitation>,
4572) -> bool {
4573    if owner_hex == actor_hex {
4574        return true;
4575    }
4576    if citation.is_none() {
4577        return false;
4578    }
4579    let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
4580    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
4581    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
4582        &crate::community::CommunityId(cid_bytes),
4583        &actor_bytes,
4584    ));
4585    let head: Vec<crate::community::roster::EntityHead> =
4586        crate::db::community::get_edition_head(cid_hex, &grant_hex)
4587            .ok()
4588            .flatten()
4589            .map(|(version, self_hash)| crate::community::roster::EntityHead {
4590                entity_hex: grant_hex.clone(),
4591                version,
4592                self_hash,
4593                inner_id: [0u8; 32],
4594                citation: None,
4595            })
4596            .into_iter()
4597            .collect();
4598    crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
4599}
4600
4601/// [`my_authority_citation`], but refusing to emit an action every reader will
4602/// drop (CORD-04 §5: an uncited non-owner action is not honored).
4603///
4604/// The citation is built from PERSISTED heads, which only `follow_control` writes
4605/// — so an admin who hasn't folded yet (just promoted, or freshly restored) would
4606/// otherwise publish uncited and have the action silently vanish on every client,
4607/// with nothing shown locally. Failing here turns that into one retryable error.
4608fn required_authority_citation(
4609    community: &CommunityV2,
4610    actor: &PublicKey,
4611) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
4612    if community.owner().ok().as_ref() == Some(actor) {
4613        return Ok(None); // supreme, cites nothing
4614    }
4615    my_authority_citation(community, actor).map(Some).ok_or_else(|| {
4616        "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
4617    })
4618}
4619
4620fn my_authority_citation(
4621    community: &CommunityV2,
4622    actor: &PublicKey,
4623) -> Option<crate::community::edition::AuthorityCitation> {
4624    if community.owner().ok().as_ref() == Some(actor) {
4625        return None;
4626    }
4627    let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
4628    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4629    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
4630    crate::db::community::get_edition_head(&cid_hex, &entity_hex)
4631        .ok()
4632        .flatten()
4633        .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
4634}
4635
4636/// Refuse a root-derived write whose in-hand struct predates a rotation.
4637///
4638/// A Ban's refound buries the old root while the caller's `CommunityV2` still
4639/// points at it; publishing there lands on a plane nobody folds — the action
4640/// "succeeds" and silently never happened (an unban that doesn't unban, an
4641/// invite that strands its joiner on a dead epoch). Failing loudly instead lets
4642/// the caller reload and retry against the living root.
4643fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
4644    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4645    match crate::db::community::get_server_root_epoch(&cid_hex)? {
4646        Some(held) if held != community.root_epoch.0 => Err(format!(
4647            "the community re-founded mid-action (epoch {} -> {held}); retry",
4648            community.root_epoch.0
4649        )),
4650        _ => Ok(()), // no row = a not-yet-persisted create; nothing newer to defer to
4651    }
4652}
4653
4654async fn publish_control_edition<T: Transport + ?Sized>(
4655    transport: &T,
4656    community: &CommunityV2,
4657    vsk: &str,
4658    entity_id: &[u8; 32],
4659    content: &str,
4660) -> Result<(), String> {
4661    crate::db::scoped(async move {
4662        assert_current_root(community)?;
4663        let signer = crate::signer::active_signer()?;
4664        let my_pk = me_pk()?;
4665        // On a split epoch the signing secret is the staff-held control_root
4666        // (CORD-02 §2) — a member without it fails HERE with a readable error
4667        // instead of minting a wrap every reader and relay drops.
4668        let view = control::ControlPlane::of(community);
4669        let control = view.write_group()?;
4670        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4671        let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
4672        let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4673            Some((v, h)) => (v + 1, Some(h)),
4674            None => (1, None),
4675        };
4676        // CORD-04 §5: a non-owner names the exact Grant edition it claims its rank
4677        // under. Computed here rather than passed in — the citation is a property of
4678        // WHO IS ACTING, identical for every entity kind, so deciding it per call
4679        // site is nine chances to forget (and nine were, silently: every site passed
4680        // None). The owner cites nothing; their rank is the community id itself.
4681        let citation = required_authority_citation(community, &my_pk)?;
4682        let at = now_ms() / 1000;
4683        let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
4684        let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
4685        transport.publish(&wrap, &community.relays).await?;
4686        // Advance our own floor so a follow-up edit chains from this head and refuse-
4687        // downgrade holds; open our own wrap to recover the self_hash + inner_id.
4688        // Re-check the session AFTER the publish await: a swap mid-publish means the
4689        // pool now points at another account's DB — skipping is safe (the next own
4690        // edit rebuilds the same head from the relay's copy).
4691        if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
4692            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
4693        }
4694        Ok(())
4695    })
4696    .await
4697}
4698
4699/// Merge our OWN just-published Role/Grant into the locally stored roster.
4700///
4701/// v2 persists the roster only inside `follow_control`, so a role or grant we
4702/// just published is invisible to every sync local read (entitlement, capability
4703/// gates, the next grant) until the next fold. This writes what we are already
4704/// authorized to have written; the next fold recomputes from the plane and
4705/// converges. Mirrors the fold's own write, so the stored `roles_at` is left
4706/// alone — a real edition always outranks this optimistic merge.
4707fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
4708    let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
4709    if let Some(r) = role {
4710        match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
4711            Some(slot) => *slot = r.clone(),
4712            None => roster.roles.push(r.clone()),
4713        }
4714    }
4715    if let Some(g) = grant {
4716        match roster.grants.iter_mut().find(|x| x.member == g.member) {
4717            Some(slot) => *slot = g.clone(),
4718            None => roster.grants.push(g.clone()),
4719        }
4720    }
4721    let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
4722    if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
4723        crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
4724    }
4725}
4726
4727/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
4728/// rename or permission change is a versioned edit of the same id. Gated on the
4729/// reader side by `MANAGE_ROLES` + outrank.
4730pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
4731    super::roles::validate_role(role)?;
4732    let content = super::roles::role_content_json(role)?;
4733    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
4734    publish_control_edition(transport, community, vsk::ROLE, &role_id, &content).await
4735}
4736
4737/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
4738/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
4739/// member. Staff-ness (whether the Grant must deliver the `control_root`,
4740/// CORD-04 §3) is judged from the persisted roster; internal callers holding a
4741/// fresher fetched view use [`grant_roles_with_roster`].
4742pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
4743    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4744    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4745    // A role this device hasn't folded is unjudgeable for staff-ness, and
4746    // guessing "not staff" publishes a staff-making Grant with NO control_wrap
4747    // — seating a staffer who cannot write, silently (CORD-04 §3 makes the wrap
4748    // mandatory on such a Grant). Resolve it from the network first; only the
4749    // definitions still missing after that fall back to the local view.
4750    // Internal callers already holding a fetched roster use
4751    // `grant_roles_with_roster` and skip this round trip.
4752    let roster = if community.control_pk.is_some() && role_ids.iter().any(|rid| roster.role(rid).is_none()) {
4753        let mut fetched = fetch_authority(transport, community).await.roles;
4754        for r in roster.roles {
4755            if !fetched.roles.iter().any(|x| x.role_id == r.role_id) {
4756                fetched.roles.push(r);
4757            }
4758        }
4759        fetched
4760    } else {
4761        roster
4762    };
4763    grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4764}
4765
4766/// The `control_wrap` a Grant carries when its role set leaves `member` staff
4767/// (CORD-04 §3): the current epoch's `control_root`, NIP-44-encrypted under
4768/// the granter↔member pairwise key, `epoch_be[8] ‖ control_root[32]` inside.
4769/// `None` when nothing is owed — a legacy pre-split epoch, or a grant that
4770/// leaves the member non-staff. Attached on EVERY staff-leaving grant, not
4771/// just the first: a re-issue with a fresh wrap is the spec's own re-delivery
4772/// path (a lost key, a head superseded before its member fetched it), and the
4773/// marginal cost is one ECDH.
4774///
4775/// Errs when a wrap is owed but this granter cannot mint one: a staff-making
4776/// edition MUST carry a wrap fresh for the current epoch — publishing without
4777/// it would seat a staffer who cannot write, silently. (The publish itself
4778/// would fail on the same missing secret, but the promotion must never outrun
4779/// the delivery.)
4780async fn control_wrap_owed(community: &CommunityV2, makes_staff: bool, member: &PublicKey) -> Result<Option<String>, String> {
4781    if !makes_staff {
4782        return Ok(None);
4783    }
4784    let Some(pk) = community.control_pk else {
4785        return Ok(None); // legacy epoch — the plane has no write key to deliver
4786    };
4787    let Some(cr) = community.control_root else {
4788        return Err("you don't hold this community's staff write key, so you can't promote to staff yet".to_string());
4789    };
4790    // Never deliver a secret that doesn't derive to the held address — the
4791    // recipient's fail-closed check would drop it anyway (CORD-04 §3).
4792    if super::derive::control_signer_group_key(&cr, community.id(), community.root_epoch).pk() != pk {
4793        return Err("this community's held staff write key is corrupt; re-sync before promoting".to_string());
4794    }
4795    use nostr_sdk::prelude::AsyncNip44;
4796    let signer = crate::signer::active_signer()?;
4797    let wrap = signer
4798        .nip44_encrypt_async(member, &super::rekey::control_wrap_b64(community.root_epoch, &cr))
4799        .await
4800        .map_err(|e| format!("staff key delivery: {e}"))?;
4801    Ok(Some(wrap))
4802}
4803
4804/// [`grant_roles`] judging staff-ness against a caller-supplied roster — the
4805/// freshest view the caller holds (a fetched authority merge, or one carrying
4806/// a role minted moments ago that no fold has read back).
4807async fn grant_roles_with_roster<T: Transport + ?Sized>(
4808    transport: &T,
4809    community: &CommunityV2,
4810    member: &PublicKey,
4811    role_ids: Vec<String>,
4812    roster: &crate::community::roles::CommunityRoles,
4813) -> Result<(), String> {
4814    // A grant that leaves the member staff carries the staff write key in the
4815    // edition itself (CORD-04 §3) — promotion and delivery are one signed
4816    // edition: nothing separate to send, race, or watch an inbox for.
4817    let control_wrap = control_wrap_owed(community, roster.roles_make_staff(&role_ids), member).await?;
4818    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
4819    let content = super::roles::grant_content_json_with_wrap(&grant, control_wrap)?;
4820    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
4821    publish_control_edition(transport, community, vsk::GRANT, &eid, &content).await
4822}
4823
4824/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
4825/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
4826/// every device, so concurrent grants converge as editions of ONE entity instead
4827/// of forking two Admin roles.
4828pub async fn ensure_admin_role<T: Transport + ?Sized>(
4829    transport: &T,
4830    community: &CommunityV2,
4831    view: &AuthorityView,
4832    create_if_missing: bool,
4833) -> Result<Option<String>, String> {
4834    use crate::community::roles::{Permissions, Role, RoleScope};
4835    // The finder tests the FROZEN founding mask, never ADMIN_ALL: published
4836    // Admin roles predate later bits (PIN_MESSAGES...), and requiring a bit
4837    // they can't have would orphan every one of them and mint a duplicate.
4838    if let Some(r) = view
4839        .roles
4840        .roles
4841        .iter()
4842        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
4843    {
4844        return Ok(Some(r.role_id.clone()));
4845    }
4846    if !create_if_missing {
4847        return Ok(None);
4848    }
4849    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4850    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
4851    set_role(transport, community, &Role::admin(role_id.clone())).await?;
4852    Ok(Some(role_id))
4853}
4854
4855/// Grant the @admin role (minting it deterministically when absent), MERGED into
4856/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
4857/// blind push would erase their other roles. Owner-only: the position-1 Admin is
4858/// manageable only by position 0 (an equal never outranks it), and refusing
4859/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
4860/// entity from advancing this device's own floor onto a head readers reject.
4861pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4862    crate::db::scoped(async move {
4863        // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
4864        // downstream publish's own (post-swap) guard write account A's floor into B.
4865        let my_pk = me_pk()?;
4866        if my_pk != community.owner()? {
4867            return Err("only the community owner can grant @admin".to_string());
4868        }
4869        let view = fetch_authority(transport, community).await;
4870        let member_hex = member.to_hex();
4871        require_grant_head(community, &view, &member_hex)?;
4872        let role_id = ensure_admin_role(transport, community, &view, true)
4873            .await?
4874            .expect("create_if_missing yields an id");
4875        let mut role_ids = view
4876            .roles
4877            .grants
4878            .iter()
4879            .find(|g| g.member == member_hex)
4880            .map(|g| g.role_ids.clone())
4881            .unwrap_or_default();
4882        if role_ids.contains(&role_id) {
4883            return Ok(()); // already admin — don't bump the grant edition for nothing.
4884        }
4885        role_ids.push(role_id.clone());
4886        // Judge staff-ness against a roster that HOLDS the admin definition: a
4887        // just-minted role hasn't folded into the fetched view yet, and missing it
4888        // would seat an admin without the write key (CORD-04 §3).
4889        let mut roster = view.roles.clone();
4890        if roster.role(&role_id).is_none() {
4891            roster.roles.push(crate::community::roles::Role::admin(role_id));
4892        }
4893        grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4894    })
4895    .await
4896}
4897
4898/// Strip the @admin role from the member's grant, preserving their other roles.
4899/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
4900pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4901    crate::db::scoped(async move {
4902        let my_pk = me_pk()?;
4903        if my_pk != community.owner()? {
4904            return Err("only the community owner can revoke @admin".to_string());
4905        }
4906        let view = fetch_authority(transport, community).await;
4907        let member_hex = member.to_hex();
4908        require_grant_head(community, &view, &member_hex)?;
4909        let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
4910            return Ok(()); // no admin role exists — nothing to revoke.
4911        };
4912        let mut role_ids = view
4913            .roles
4914            .grants
4915            .iter()
4916            .find(|g| g.member == member_hex)
4917            .map(|g| g.role_ids.clone())
4918            .unwrap_or_default();
4919        let before = role_ids.len();
4920        role_ids.retain(|r| r != &role_id);
4921        if role_ids.len() == before {
4922            return Ok(());
4923        }
4924        // A demotion can still leave the member staff through a KEPT role — the
4925        // fetched view holds every kept definition, so judge against it.
4926        grant_roles_with_roster(transport, community, member, role_ids, &view.roles).await
4927    })
4928    .await
4929}
4930
4931/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
4932/// locally but no head folded (withheld / evicted): a blind push at that point
4933/// would erase their other roles at a higher version.
4934fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
4935    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
4936        return Err("malformed member key".to_string());
4937    };
4938    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
4939    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
4940        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
4941    }
4942    Ok(())
4943}
4944
4945/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
4946/// whole list on every edit. Gated on the reader side by `BAN` per head plus strict
4947/// outrank per entry — mirrored here BEFORE the publish, so an unauthorized caller
4948/// (an SDK bot without the bit, a demoted moderator) fails loudly instead of
4949/// publishing an edition every reader silently rejects while its own local echo
4950/// caches the phantom ban.
4951pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
4952    crate::db::scoped(async move {
4953        super::roles::validate_banlist(banned)?;
4954        {
4955            let my_pk = me_pk()?;
4956            let owner = community.owner()?;
4957            if my_pk != owner {
4958                let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4959                let me_hex = my_pk.to_hex();
4960                let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4961                if current.contains(&me_hex) {
4962                    return Err("you are banned from this community".to_string());
4963                }
4964                let roster = crate::db::community::get_community_roles(&cid_hex)?;
4965                let owner_hex = owner.to_hex();
4966                if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
4967                    return Err("editing the banlist needs the BAN permission".to_string());
4968                }
4969                // Only the entries this edit ADDS need the per-target outrank (the fold
4970                // keeps prior authorized bans alive through its per-candidate history).
4971                for target in banned.iter().filter(|t| !current.contains(*t)) {
4972                    if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
4973                        return Err("you do not outrank a member this ban targets".to_string());
4974                    }
4975                }
4976            }
4977        }
4978        let content = super::roles::banlist_content_json(banned)?;
4979        let eid = super::derive::banlist_locator(community.id());
4980        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4981        let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
4982        // The version this publish will chain to — mirrors publish_control_edition.
4983        let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4984            Some((v, _)) => v + 1,
4985            None => 1,
4986        };
4987        publish_control_edition(transport, community, vsk::BANLIST, &eid, &content).await?;
4988        // ECHO the published list into the local cache at once. Without this, the
4989        // cache only moves on a successful control-plane fold — and a caller
4990        // composing ban steps (banlist → grant strip → refound) re-reads the STALE
4991        // list if any later step trips before the fold, so each new banlist
4992        // edition it builds ERASES every ban since the last fold. Nineteen bans in
4993        // production each overwrote their predecessor exactly this way.
4994        let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
4995        Ok(())
4996    })
4997    .await
4998}
4999
5000/// Per-community moderation-unit locks. Session-scoped: an account swap drops the
5001/// map with its session, so no cross-account carry-over and nothing to clear.
5002struct ModerationLocks;
5003fn moderation_lock(cid: &crate::community::CommunityId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
5004    let map = crate::db::current_session()
5005        .scoped::<ModerationLocks, std::sync::Mutex<std::collections::HashMap<[u8; 32], std::sync::Arc<tokio::sync::Mutex<()>>>>>();
5006    let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5007    m.entry(cid.0).or_default().clone()
5008}
5009
5010/// Fold control + guestbook so a moderation unit composes over the freshest view
5011/// another moderator may have advanced. Best-effort: on a dead network the local
5012/// cache (kept fresh by `set_banlist`'s own echo) is still the best basis.
5013async fn converge_authority<T: Transport + ?Sized>(transport: &T, id: &crate::community::CommunityId) {
5014    if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
5015        let _ = follow_control(transport, &fresh).await;
5016        // Membership is part of the converged view: a Join that legally raced a ban
5017        // window may exist only on the relays, and our own just-published edition
5018        // doesn't echo back to trigger a follow.
5019        if let Ok(added) = sync_guestbook(transport, &fresh).await {
5020            if !added.is_empty() {
5021                crate::traits::emit_event_json(
5022                    "community_refreshed",
5023                    serde_json::json!({ "community_id": crate::simd::hex::bytes_to_hex_32(&id.0) }),
5024                );
5025            }
5026        }
5027    }
5028}
5029
5030/// One queued ban/unban intent, answered on `done` when its batch completes.
5031struct ModIntent {
5032    id: u64,
5033    targets: Vec<PublicKey>,
5034    banned: bool,
5035    done: tokio::sync::oneshot::Sender<Result<(), String>>,
5036}
5037
5038/// Per-community pending moderation intents — the coalescing queue behind
5039/// [`moderation_lock`]. Session-scoped for the same reason as the lock.
5040struct PendingModeration;
5041fn pending_moderation(cid: &crate::community::CommunityId) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>> {
5042    let _ = cid;
5043    crate::db::current_session()
5044        .scoped::<PendingModeration, std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>>()
5045}
5046
5047/// Ban or unban members as ONE moderation unit — the CORD-04 §6 three-removal
5048/// composition, once for the whole wave: one Banlist edition (instant silence),
5049/// one grant-strip pass (authority removal), and one read cut (a private
5050/// community re-founds ONCE with every target removed; a public one rotates each
5051/// reachable private channel ONCE excluding them all).
5052///
5053/// Serialized AND coalesced per community. Without the lock, concurrent bans
5054/// (the SDK spawns a handler task per message, so a spam wave is exactly this)
5055/// each build their edition from a pre-sibling banlist snapshot, and the fold
5056/// takes the HEAD's content — so the last racing head silently un-bans the
5057/// earlier target at every reader, and the refound then compacts that erasure
5058/// into the new epoch permanently.
5059///
5060/// The coalescing half: a unit's read cut holds the lock for a whole multi-
5061/// publish rotation, so serial single bans arriving meanwhile would each queue
5062/// up a rotation of their own. Instead every caller deposits an intent; whoever
5063/// next takes the lock drains ALL of them into one edition + one read cut,
5064/// applied in arrival order (ban-then-unban of one npub nets to unban). Each
5065/// intent is judged individually — a target the caller cannot outrank, or an
5066/// add past the 500-entry ceiling, fails only its own caller, never the batch.
5067pub async fn set_members_banned<T: Transport + ?Sized>(
5068    transport: &T,
5069    community_id: &crate::community::CommunityId,
5070    members: &[PublicKey],
5071    banned: bool,
5072) -> Result<(), String> {
5073    crate::db::scoped(async move {
5074        // Dedup — a doubled target must not strip or rotate twice.
5075        let mut targets: Vec<PublicKey> = Vec::with_capacity(members.len());
5076        for m in members {
5077            if !targets.contains(m) {
5078                targets.push(*m);
5079            }
5080        }
5081        if targets.is_empty() {
5082            return Ok(());
5083        }
5084        // Plain uniqueness source for intent ids — not per-account state.
5085        static INTENT_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
5086        let my_id = INTENT_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5087        let (tx, rx) = tokio::sync::oneshot::channel();
5088        {
5089            let map = pending_moderation(community_id);
5090            let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5091            m.entry(community_id.0).or_default().push(ModIntent { id: my_id, targets, banned, done: tx });
5092        }
5093        let unit = moderation_lock(community_id);
5094        // `biased` so a verdict delivered by another leader wins over a lock we
5095        // happen to also hold — a drained intent must never run twice.
5096        tokio::select! {
5097            biased;
5098            res = rx => res.unwrap_or_else(|_| Err("moderation unit aborted before completing".to_string())),
5099            _guard = unit.lock() => {
5100                // Leader: drain EVERYTHING pending right now. Every ban/unban that
5101                // stacked while the previous unit's rotation ran coalesces here.
5102                let batch: Vec<ModIntent> = {
5103                    let map = pending_moderation(community_id);
5104                    let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5105                    m.remove(&community_id.0).unwrap_or_default()
5106                };
5107                run_moderation_batch(transport, community_id, batch, my_id).await
5108            }
5109        }
5110    })
5111    .await
5112}
5113
5114/// Execute one drained batch of moderation intents under the held lock: judge
5115/// each intent in arrival order against the evolving list, publish the coalesced
5116/// edition, strip + read-cut the net newly-banned set once, then answer every
5117/// intent with its own verdict. Returns the LEADER's verdict.
5118async fn run_moderation_batch<T: Transport + ?Sized>(
5119    transport: &T,
5120    community_id: &crate::community::CommunityId,
5121    batch: Vec<ModIntent>,
5122    leader_id: u64,
5123) -> Result<(), String> {
5124    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
5125
5126    // SYNC BEFORE COMPOSING: a banlist edition replaces the whole list on the
5127    // wire, so the mutation must start from the freshest view — another
5128    // moderator's ban this device hasn't folded yet would otherwise be
5129    // silently erased by ours.
5130    converge_authority(transport, community_id).await;
5131
5132    let original = crate::db::community::get_community_banlist(&cid_hex)?;
5133    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5134    let community0 = crate::db::community::load_community_v2(community_id)?
5135        .ok_or_else(|| "v2 community not found".to_string())?;
5136    let owner = community0.owner()?;
5137    let my_pk = me_pk()?;
5138    let my_hex = my_pk.to_hex();
5139    let owner_hex = owner.to_hex();
5140
5141    // Judge intents in arrival order against the EVOLVING list, so the last
5142    // intent naming an npub wins — exactly serial semantics, minus the churn.
5143    let mut list = original.clone();
5144    let mut pk_of: std::collections::HashMap<String, PublicKey> = std::collections::HashMap::new();
5145    let mut verdicts: std::collections::HashMap<u64, Result<(), String>> = std::collections::HashMap::new();
5146    for intent in &batch {
5147        let hexes: Vec<String> = intent.targets.iter().map(|p| p.to_hex()).collect();
5148        for (pk, hex) in intent.targets.iter().zip(&hexes) {
5149            pk_of.insert(hex.clone(), *pk);
5150        }
5151        let verdict = (|| {
5152            // Mirror of set_banlist's authority gate, per intent, so one refused
5153            // intent cannot sink its batch-mates.
5154            if my_pk != owner {
5155                if original.contains(&my_hex) {
5156                    return Err("you are banned from this community".to_string());
5157                }
5158                if !roster.is_authorized(&my_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
5159                    return Err("editing the banlist needs the BAN permission".to_string());
5160                }
5161                if intent.banned {
5162                    for t in hexes.iter().filter(|t| !list.contains(*t)) {
5163                        if !roster.can_act_on_member(&my_hex, Some(&owner_hex), t, crate::community::roles::Permissions::BAN) {
5164                            return Err("you do not outrank a member this ban targets".to_string());
5165                        }
5166                    }
5167                }
5168            }
5169            let mut next = list.clone();
5170            next.retain(|h| !hexes.contains(h));
5171            if intent.banned {
5172                next.extend(hexes.iter().cloned());
5173            }
5174            super::roles::validate_banlist(&next)?;
5175            list = next;
5176            Ok(())
5177        })();
5178        verdicts.insert(intent.id, verdict);
5179    }
5180
5181    let changed = {
5182        let mut a = original.clone();
5183        let mut b = list.clone();
5184        a.sort();
5185        b.sort();
5186        a != b
5187    };
5188    let newly_banned: Vec<PublicKey> = list
5189        .iter()
5190        .filter(|h| !original.contains(*h))
5191        .filter_map(|h| pk_of.get(h).copied())
5192        .collect();
5193
5194    // Publish the coalesced edition + strips, then the single read cut.
5195    let unit_result: Result<(), String> = async {
5196        if !changed {
5197            return Ok(());
5198        }
5199        // Rotation barrier: a sibling refound holds this lock for its whole
5200        // multi-publish rotation while the row still names the OLD root. Dropped
5201        // before `refound_community`, which re-acquires it (non-reentrant).
5202        let (community, stripped) = {
5203            let lock = super::realtime::follow_lock(community_id);
5204            let _rotation = lock.lock().await;
5205            let community = crate::db::community::load_community_v2(community_id)?
5206                .ok_or_else(|| "v2 community not found".to_string())?;
5207            set_banlist(transport, &community, &list).await?;
5208            let mut stripped: Vec<(PublicKey, Vec<String>)> = Vec::new();
5209            // Pre-strip role capture: the read-severance below judges which
5210            // private channels each member could reach, and after the strip
5211            // the fold may no longer show it.
5212            for pk in &newly_banned {
5213                let hex = pk.to_hex();
5214                let roles: Vec<String> = roster.roles_of(&hex).map(|r| r.role_id.clone()).collect();
5215                // Nothing granted = nothing to strip: a bulk ban of roleless
5216                // spammers must not publish one empty Grant edition apiece.
5217                let has_grant = roster.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
5218                if has_grant {
5219                    // Strip on the kick doctrine (skip, not refuse): readers honor a
5220                    // grant strip only from MANAGE_ROLES + outrank, and a BAN-only
5221                    // moderator still bans — the banlist silences regardless, and
5222                    // publishing a strip readers reject would only poison our own
5223                    // floor for that grant entity.
5224                    let can_strip = my_pk == owner
5225                        || roster.can_act_on_member(&my_hex, Some(&owner_hex), &hex, crate::community::roles::Permissions::MANAGE_ROLES);
5226                    if can_strip {
5227                        grant_roles(transport, &community, pk, vec![]).await?;
5228                    } else {
5229                        crate::log_warn!("[Ban] grant strip skipped (no MANAGE_ROLES over the target); the banlist still silences");
5230                    }
5231                }
5232                stripped.push((*pk, roles));
5233            }
5234            (community, stripped)
5235        };
5236        if !newly_banned.is_empty() {
5237            // CORD-05 §5 gate: a PUBLIC community — any live invite link — must NOT
5238            // refound on ban. The link refresh re-posts the bundle with the new root
5239            // behind the same URL, so the banned member re-fetches and reads on: the
5240            // rotation severs nothing and can strand foreign-link joiners on a buried
5241            // epoch. The Banlist does all the real work in Public mode.
5242            if community_is_public(transport, &community).await {
5243                crate::log_info!("[Ban] public community — banlist + grant strip, no refound (CORD-05 §5)");
5244                // CORD-06 §1 still applies per channel: only rotation severs the
5245                // private-channel reads their held keys still allow.
5246                match sever_banned_private_reads(transport, &community, &stripped).await {
5247                    Ok(0) => {}
5248                    Ok(n) => crate::log_info!("[Ban] rotated {n} private channel(s) the banned member(s) could read"),
5249                    Err(e) => crate::log_warn!("[Ban] private-channel read severance incomplete: {e}"),
5250                }
5251            } else if let Err(e) = refound_community(transport, &community, &newly_banned).await {
5252                // Best-effort escalation, NEVER the ban's verdict. The banlist
5253                // edition (instant silence at every honest reader) and the grant
5254                // strip ARE the ban; the refound's §3 coverage gate demands the
5255                // relay serve back heads published milliseconds ago, so propagation
5256                // lag makes it fail-closed — and propagating that error out of here
5257                // once left every ban half-applied AND skipped the converge below,
5258                // so each next ban rebuilt from a stale list and erased its
5259                // predecessors.
5260                crate::log_warn!("[Ban] refound deferred (banlist + grant strip landed): {e}");
5261            }
5262        }
5263        Ok(())
5264    }
5265    .await;
5266    converge_authority(transport, community_id).await;
5267
5268    // Answer every intent: its own judgment first, then the shared publish fate.
5269    let mut my_verdict = Err("leader intent missing from its own batch".to_string());
5270    for intent in batch {
5271        let final_verdict = verdicts
5272            .remove(&intent.id)
5273            .unwrap_or_else(|| Err("intent was never judged".to_string()))
5274            .and_then(|()| unit_result.clone());
5275        if intent.id == leader_id {
5276            my_verdict = final_verdict;
5277        } else {
5278            let _ = intent.done.send(final_verdict);
5279        }
5280    }
5281    my_verdict
5282}
5283
5284// ── Raid containment (CORD-06 Severing Refounding) ──────────────────────────
5285
5286/// What a containment actually accomplished. Every field is an honest count —
5287/// a caller (Sentinel, a moderation console) reports these to a human, so a
5288/// half-done containment must never read as a closed door.
5289#[derive(Debug, Clone, Default, serde::Serialize)]
5290pub struct ContainmentReport {
5291    /// Raiders added to the Banlist.
5292    pub banned: usize,
5293    /// Members cut for joining inside the raid window without being convicted.
5294    pub window_cut: usize,
5295    /// Root epoch before and after. Equal = the refound did NOT land.
5296    pub epoch_before: u64,
5297    pub epoch_after: u64,
5298    /// True only if the severing refound published.
5299    pub refound_ok: bool,
5300    /// My own public links revoked here. Other creators' links die as their
5301    /// clients adopt the severed rotation.
5302    pub own_links_revoked: usize,
5303    /// Creators whose links are still live pending their client's adoption.
5304    pub foreign_link_creators: Vec<String>,
5305    /// Non-fatal problems worth showing a human.
5306    pub warnings: Vec<String>,
5307}
5308
5309/// Contain a raid: ban the raiders, cut everyone who walked in through the door
5310/// during the raid window, then perform a SEVERING Refounding — the rotation
5311/// that revokes the invite links instead of carrying them into the new epoch.
5312///
5313/// This is the raid lane, not a general ban. An ordinary ban (even in a Public
5314/// community) keeps its shipped behaviour — [`set_members_banned`] decides
5315/// internally, and a public one deliberately does NOT roll the root. Here the
5316/// door itself is the vector, so it closes with the rotation.
5317///
5318/// `window_start_secs` is the raid's opening edge: unconvicted members who
5319/// joined at-or-after it are cut from the new epoch (NOT banned — an innocent
5320/// caught in the window rejoins through the owner's next link). Members already
5321/// present before the window are never cut, however quiet.
5322pub async fn contain_raid<T: Transport + ?Sized>(
5323    transport: &T,
5324    community_id: &crate::community::CommunityId,
5325    raiders: &[PublicKey],
5326    window_start_secs: u64,
5327    cut_also: &[PublicKey],
5328) -> Result<ContainmentReport, String> {
5329    crate::db::scoped(async move {
5330        let mut report = ContainmentReport::default();
5331        let community = crate::db::community::load_community_v2(community_id)?
5332            .ok_or_else(|| "v2 community not found".to_string())?;
5333        report.epoch_before = community.root_epoch.0;
5334        report.epoch_after = community.root_epoch.0;
5335        let owner = community.owner()?;
5336        let my_pk = me_pk()?;
5337
5338        // 1. Ban the convicted. Goes through the coalescer: the banlist edition is
5339        //    a read-modify-write, and a concurrent single ban would otherwise
5340        //    rebuild from a stale list and erase this one. A FAILED ban (most
5341        //    plausibly the 500-entry banlist ceiling under a mega-raid) must not
5342        //    abort the containment: the refound below cuts the raiders
5343        //    cryptographically whether or not the list could name them.
5344        if !raiders.is_empty() {
5345            match set_members_banned(transport, community_id, raiders, true).await {
5346                Ok(()) => report.banned = raiders.len(),
5347                Err(e) => report.warnings.push(format!("banlist did not land ({e}) — the refound still cuts every named raider")),
5348            }
5349        }
5350
5351        // Re-read: the ban published a Banlist edition and stripped grants.
5352        let community = crate::db::community::load_community_v2(community_id)?
5353            .ok_or_else(|| "v2 community not found".to_string())?;
5354
5355        // 2. The window cut. A raid arrives THROUGH the link, so everyone who
5356        //    walked in during it is part of the wave for containment purposes —
5357        //    conviction only ever names the ones who spoke. Never the owner, never
5358        //    staff, never anyone whose earliest trace predates the window.
5359        //
5360        //    `cut_also` first: Join timestamps are AUTHOR-claimed, so a quiet
5361        //    raider who backdated their Join slides under any window this fold
5362        //    can compute. A caller watching arrivals on its own clock (Sentinel's
5363        //    receive log) names them here and they are cut regardless.
5364        let mut cut: Vec<PublicKey> = raiders.to_vec();
5365        for pk in cut_also {
5366            if !cut.contains(pk) {
5367                cut.push(*pk);
5368                report.window_cut += 1;
5369            }
5370        }
5371        if window_start_secs > 0 {
5372            let roster = crate::db::community::get_community_roles(
5373                &crate::simd::hex::bytes_to_hex_32(&community.id().0),
5374            )
5375            .unwrap_or_default();
5376            let owner_hex = owner.to_hex();
5377            match fetch_guestbook_events(transport, &community, 0).await {
5378                Ok((events, _, _)) => {
5379                    // EARLIEST trace per member: a re-announced Join must never make
5380                    // an established member read as a window joiner.
5381                    let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
5382                    for ev in &events {
5383                        if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
5384                            let secs = at_ms / 1000;
5385                            first_seen
5386                                .entry(*member)
5387                                .and_modify(|e| *e = (*e).min(secs))
5388                                .or_insert(secs);
5389                        }
5390                    }
5391                    for (pk, joined) in first_seen {
5392                        if joined < window_start_secs || pk == owner || pk == my_pk || cut.contains(&pk) {
5393                            continue;
5394                        }
5395                        // Staff are never cut by a window: a moderator who joined
5396                        // moments before a raid is still a moderator.
5397                        if roster.is_staff(&pk.to_hex(), Some(&owner_hex)) {
5398                            continue;
5399                        }
5400                        cut.push(pk);
5401                        report.window_cut += 1;
5402                    }
5403                }
5404                Err(e) => report
5405                    .warnings
5406                    .push(format!("guestbook unreadable — no window cut applied, only convicted raiders are severed ({e})")),
5407            }
5408        }
5409
5410        // 3. The severing refound. Private channels are deliberately NOT rotated
5411        //    separately: the root roll re-keys every plane, and link-borne raiders
5412        //    were never entitled to a private channel in the first place.
5413        //
5414        //    Under the MODERATION lock, exactly as `run_moderation_batch` holds it
5415        //    across ITS refound: an ordinary ban racing this rotation would publish
5416        //    a banlist edition the compaction fetch below has already read past,
5417        //    and the new epoch would carry it away — a ban silently erased at the
5418        //    moment bans matter most. Lock order (moderation → follow) matches the
5419        //    shipped path. Retried: the refound's coverage gate fails closed under
5420        //    exactly the relay lag a mid-raid flood produces.
5421        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5422        let links_before = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5423        let unit = moderation_lock(community_id);
5424        let _guard = unit.lock().await;
5425        let mut refound = Err("severing refound never attempted".to_string());
5426        for attempt in 0..3u8 {
5427            refound = refound_community_severing(transport, &community, &cut, window_start_secs).await;
5428            if refound.is_ok() {
5429                break;
5430            }
5431            if attempt < 2 {
5432                tokio::time::sleep(std::time::Duration::from_millis(400 * (attempt as u64 + 1))).await;
5433            }
5434        }
5435        match refound {
5436            Ok(updated) => {
5437                report.refound_ok = true;
5438                report.epoch_after = updated.root_epoch.0;
5439                let links_after = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5440                report.own_links_revoked = links_before.saturating_sub(links_after);
5441                if links_after > 0 {
5442                    report.warnings.push(format!(
5443                        "{links_after} of my own link(s) survived the sever — the mark retries them on the next follow"
5444                    ));
5445                }
5446                report.foreign_link_creators = crate::db::community::invite_link_set_creators(&cid_hex)
5447                    .unwrap_or_default()
5448                    .into_iter()
5449                    .filter(|c| *c != my_pk.to_hex())
5450                    .collect();
5451            }
5452            Err(e) => {
5453                // The banlist landed and silences the named raiders at every honest
5454                // reader, but the DOOR IS STILL OPEN. Never dress this as success.
5455                report.warnings.push(format!("SEVERING REFOUND FAILED — the invite door may still be open: {e}"));
5456            }
5457        }
5458        Ok(report)
5459    })
5460    .await
5461}
5462
5463/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
5464/// `MANAGE_METADATA`.
5465pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
5466    ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
5467    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
5468    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5469    publish_control_edition(transport, community, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
5470}
5471
5472/// Persist a freshly-published icon/banner onto the held row and return the fresh
5473/// row. Reloads under the community's follow lock: `save_community_v2` is a
5474/// whole-row save that prunes channels absent from the passed struct, so writing
5475/// a stale pre-upload copy would drop rows a concurrent fold just landed.
5476pub async fn persist_community_image(
5477    id: &crate::community::CommunityId,
5478    img: control::ImageRef,
5479    is_banner: bool,
5480) -> Option<CommunityV2> {
5481    let lock = super::realtime::follow_lock(id);
5482    let _guard = lock.lock().await;
5483    let mut fresh = crate::db::community::load_community_v2(id).ok()??;
5484    if is_banner {
5485        fresh.banner = Some(img);
5486    } else {
5487        fresh.icon = Some(img);
5488    }
5489    crate::db::community::save_community_v2(&fresh).ok()?;
5490    Some(fresh)
5491}
5492
5493/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
5494/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
5495pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
5496    let my_pk = me_pk()?;
5497    ensure_channel_manager(community, &my_pk)?;
5498    let old_name = community.channel(channel_id).map(|c| c.name.clone());
5499    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
5500    // mint yet — refuse the flag flip rather than publish an edition no reader can
5501    // key (members would keep posting on the root-derived plane, splitting the
5502    // channel). Private → public works (readers heal to the root derivation).
5503    if meta.private {
5504        if let Some(held) = community.channel(channel_id) {
5505            if !held.private {
5506                return Err("converting a public channel to private is not supported yet".to_string());
5507            }
5508        }
5509    }
5510    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
5511    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5512    publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5513    // Apply locally too. The fold is the authority but runs later, so without this
5514    // an edit we just made reads back stale until some future control pass — the
5515    // rename appears to have silently failed.
5516    if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
5517        if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
5518            ch.name = meta.name.clone();
5519            ch.private = meta.private;
5520            ch.voice = meta.voice;
5521            ch.meta_custom = meta.custom.clone();
5522            ch.meta_extra = meta.extra.clone();
5523            crate::db::community::save_community_v2(&held)?;
5524        }
5525    }
5526    // Keep the companion access role's label in step with the channel it gates.
5527    if meta.private {
5528        if let Some(old) = old_name.filter(|o| *o != meta.name) {
5529            rename_channel_access_role(transport, community, channel_id, &old, &meta.name).await;
5530        }
5531    }
5532    Ok(())
5533}
5534
5535/// Rename a private channel's companion access role to follow the channel (CORD-04 §2).
5536/// Best-effort and never fatal: the channel rename has already published, and a role's
5537/// name is cosmetic — entitlement is carried by the scope, not the label.
5538///
5539/// Only renames a label still equal to the channel's OLD name, so a deliberately
5540/// customised role name survives a channel rename untouched.
5541async fn rename_channel_access_role<T: Transport + ?Sized>(
5542    transport: &T,
5543    community: &CommunityV2,
5544    channel_id: &ChannelId,
5545    old_name: &str,
5546    new_name: &str,
5547) {
5548    crate::db::scoped(async move {
5549        let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
5550            return;
5551        };
5552        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5553        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5554        // Fetched, not cached: `set_role` republishes the WHOLE role body, so a stale
5555        // cache would clobber a permission edit this client has not folded yet.
5556        let mut roster = fetch_authority(transport, community).await.roles;
5557        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5558        for r in cached.roles {
5559            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5560                roster.roles.push(r);
5561            }
5562        }
5563        // MANAGE_CHANNELS got us the rename; the role edition needs MANAGE_ROLES + outrank
5564        // of its own. Publishing one readers reject would wedge our later, legitimate role
5565        // edits behind a rejected chain, so verify before publishing rather than after.
5566        let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
5567        if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5568            return;
5569        }
5570        // Same selector `grant_channel_access` vends: the permission-less scoped role. A
5571        // per-channel moderator role sharing the scope is NOT the access list.
5572        let Some(mut role) = roster
5573            .channel_roles(&chan_hex)
5574            .into_iter()
5575            .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
5576            .cloned()
5577        else {
5578            return;
5579        };
5580        if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
5581            return;
5582        }
5583        role.name = new_name.to_string();
5584        if let Err(e) = set_role(transport, community, &role).await {
5585            crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
5586            return;
5587        }
5588        merge_local_roster(&cid_hex, Some(&role), None);
5589    })
5590    .await
5591}
5592
5593/// The local mirror of a reader-side permission fold gate: the owner, or a
5594/// roster-authorized holder of `needed` who isn't banned. Refusing BEFORE any
5595/// publish keeps an unauthorized device from advancing its own edition floor onto
5596/// a head every reader rejects (wedging its later, legitimately-authorized edits
5597/// behind a rejected chain). Fail-closed: an empty/unfolded roster collapses to
5598/// owner-only — it can over-restrict, never grant authority no one has.
5599fn ensure_folded_permission(
5600    community: &CommunityV2,
5601    me: &PublicKey,
5602    needed: u64,
5603    verb: &str,
5604) -> Result<(), String> {
5605    let owner = community.owner()?;
5606    if *me == owner {
5607        return Ok(());
5608    }
5609    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5610    let me_hex = me.to_hex();
5611    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
5612        return Err("you are banned from this community".to_string());
5613    }
5614    let roster = crate::db::community::get_community_roles(&cid_hex)?;
5615    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
5616        Ok(())
5617    } else {
5618        Err(format!("{verb} needs the {} permission", permission_label(needed)))
5619    }
5620}
5621
5622fn permission_label(bits: u64) -> &'static str {
5623    use crate::community::roles::Permissions;
5624    match bits {
5625        Permissions::MANAGE_ROLES => "MANAGE_ROLES",
5626        Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
5627        Permissions::MANAGE_METADATA => "MANAGE_METADATA",
5628        Permissions::BAN => "BAN",
5629        Permissions::CREATE_INVITE => "CREATE_INVITE",
5630        _ => "required",
5631    }
5632}
5633
5634/// [`ensure_folded_permission`] for `MANAGE_CHANNELS` (CORD-03 §2).
5635fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
5636    ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
5637}
5638
5639/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
5640/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
5641/// Plane from the `community_root` (no per-channel key), so other members fold it in on
5642/// their next control follow with nothing to distribute. Returns the new channel id.
5643/// Reader-gated by `MANAGE_CHANNELS`.
5644pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
5645    let channel_id = ChannelId(super::super::random_32());
5646    create_public_channel_with_id(transport, community, name, channel_id).await?;
5647    Ok(channel_id)
5648}
5649
5650/// [`create_public_channel`] with a CALLER-CHOSEN id — the migration-only entry point
5651/// (§migration) that reuses a v1 channel's id so chat history stitches through the flip.
5652/// Asserts the id isn't already live in a DIFFERENT held v2 community before minting.
5653pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
5654    crate::db::scoped(async move {
5655        // Serialize with the follow worker: the save below writes the WHOLE community
5656        // row from this caller's struct, so an unserialized concurrent follow adopting
5657        // a rotation would be rolled back to a stale root (a deaf community).
5658        let lock = super::realtime::follow_lock(community.id());
5659        let _guard = lock.lock().await;
5660        let my_pk = me_pk()?;
5661        ensure_channel_manager(community, &my_pk)?;
5662        assert_channel_id_free(&channel_id, community.id())?;
5663        let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
5664        control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
5665        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
5666        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5667        // Add locally + persist so the creator can post immediately (peers fold it in).
5668        let mut updated = community.clone();
5669        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() });
5670        crate::db::community::save_community_v2(&updated)?;
5671        Ok(())
5672    })
5673    .await
5674}
5675
5676/// Refuse a channel id already live in a DIFFERENT held v2 community — the same
5677/// cross-community hijack the `save_community_v2` guard forecloses, checked up front so a
5678/// migration twin never adopts an id it doesn't own. A collision with a v1-owned row is
5679/// fine (that's the whole point — the flip re-parents it); only a foreign v2 owner blocks.
5680fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
5681    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5682    if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
5683        let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
5684        let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
5685        if existing != mine
5686            && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
5687        {
5688            return Err("channel id is already live in another v2 community".to_string());
5689        }
5690    }
5691    Ok(())
5692}
5693
5694/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
5695/// random key at channel-epoch 1, mint a companion channel-scoped Role that is
5696/// the channel's access list (CORD-04 §2), deliver the key to the entitled over
5697/// the rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`).
5698/// Epoch 0 is the root generation ("the first privatisation is epoch 1"), so the
5699/// delivery commits its continuity to `(0, community_root)` — verifiable by every
5700/// member and bound to THIS community's root. The key ships BEFORE the
5701/// announcement: an aborted attempt leaves only an unannounced crate (invisible),
5702/// and a retry mints a fresh id, so there is no same-coordinate double-mint to
5703/// fork on. Live public links are refreshed; they carry no private key, so this
5704/// only re-states the public set.
5705pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
5706    let channel_id = ChannelId(super::super::random_32());
5707    create_private_channel_with_id(transport, community, name, channel_id).await?;
5708    Ok(channel_id)
5709}
5710
5711/// The companion Role minted alongside a Private channel — the channel's access
5712/// list (CORD-04 §2 `scope: {"kind":"channel"}`). Same name as the channel, and
5713/// **no permission bits**: it confers read access, which is key possession, never
5714/// authority. Position sits below every management role for the same reason.
5715pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
5716    use crate::community::roles::{Permissions, Role, RoleScope};
5717    Role {
5718        role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
5719        name: name.to_string(),
5720        position: u32::MAX - 1,
5721        permissions: Permissions::empty(),
5722        scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
5723        color: 0,
5724    }
5725}
5726
5727/// [`create_private_channel`] with a CALLER-CHOSEN id — the migration-only entry point
5728/// (§migration) reusing a v1 private channel's id so history stitches through the flip.
5729pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
5730    crate::db::scoped(async move {
5731        // Serialize with the follow worker across the whole fetch→publish→save span
5732        // (the memberlist fetch is seconds long; an unserialized follow adopting a
5733        // rotation meanwhile would be rolled back by the whole-row save below).
5734        let lock = super::realtime::follow_lock(community.id());
5735        let _guard = lock.lock().await;
5736        let signer = crate::signer::active_signer()?;
5737        let my_pk = me_pk()?;
5738        ensure_channel_manager(community, &my_pk)?;
5739        assert_channel_id_free(&channel_id, community.id())?;
5740        let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
5741        control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
5742        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
5743
5744        let channel_key = super::super::random_32();
5745        let epoch = Epoch(1);
5746
5747        // The channel's access list: a companion channel-scoped Role (CORD-04 §2),
5748        // granted to me so the creator is entitled from the first edition.
5749        let access_role = channel_access_role(&channel_id, name);
5750        let access_role_ids = vec![access_role.role_id.clone()];
5751
5752        // Recipients are the ENTITLED, not the memberlist: CORD-03's private channel
5753        // is "readable only by granted role-holders". At create that is me (plus the
5754        // owner, who is always entitled) — everyone else keys up when granted.
5755        let owner = community.owner()?;
5756        let mut recipients = vec![my_pk];
5757        if owner != my_pk {
5758            recipients.push(owner);
5759        }
5760        let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
5761        let mut blobs = Vec::with_capacity(recipients.len());
5762        for r in &recipients {
5763            blobs.push(
5764                rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
5765                    .await
5766                    .map_err(|e| e.to_string())?,
5767            );
5768        }
5769        let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
5770        let at_secs = now_ms() / 1000;
5771        let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
5772            .await
5773            .map_err(|e| e.to_string())?;
5774        for c in &chunks {
5775            transport.publish_durable(c, &community.relays).await?;
5776        }
5777        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5778        // Publish the access list AFTER the channel exists, so a peer folding the
5779        // Role always resolves the channel it scopes to. A failure here leaves a
5780        // channel only its creator can read — recoverable by re-granting, never a
5781        // leak.
5782        set_role(transport, community, &access_role).await?;
5783        // Judge staff-ness against a roster that HOLDS the role just minted above —
5784        // no fold has read it back yet, and the access role is permission-less, so
5785        // resolving it locally both skips a round trip and gets the right answer.
5786        let mut roster_with_access = crate::db::community::get_community_roles(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap_or_default();
5787        if roster_with_access.role(&access_role.role_id).is_none() {
5788            roster_with_access.roles.push(access_role.clone());
5789        }
5790        grant_roles_with_roster(transport, community, &my_pk, access_role_ids.clone(), &roster_with_access).await?;
5791        // The fold is the authority but runs later; without this the creator is not
5792        // yet entitled to their own channel and the next grant finds no access role.
5793        merge_local_roster(
5794            &crate::simd::hex::bytes_to_hex_32(&community.id().0),
5795            Some(&access_role),
5796            Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
5797        );
5798        // A leave/delete raced the create: saving would resurrect the community row.
5799        if crate::db::community::community_protocol(community.id())?.is_none() {
5800            return Err("community removed during channel create".to_string());
5801        }
5802        let mut updated = community.clone();
5803        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() });
5804        crate::db::community::save_community_v2(&updated)?;
5805        // Archive the epoch-1 key so this channel's history stays readable across its
5806        // future rotations (CORD-03 §3).
5807        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5808        crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
5809        // Re-state live links. They carry no private key (CORD-05 §2 — a link's
5810        // audience holds no Role), so this only refreshes the public set.
5811        let _ = refresh_public_links(transport, &updated).await;
5812        Ok(())
5813    })
5814    .await
5815}
5816
5817/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
5818// ── Receiving a key vend (CORD-03 "delivered on grant") ──────────────────────
5819
5820/// What a client should do with a vended Private-Channel key right now.
5821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5822pub enum VendVerdict {
5823    /// Every rule passed — adopt the key.
5824    Accept,
5825    /// Cannot judge YET: our fold lags the grant it delivers. Park quietly and
5826    /// re-judge after the next control follow. NOT an anomaly — a lagging fold
5827    /// is the normal case for a vend that races its own Grant.
5828    Park(&'static str),
5829    /// Judged invalid against evidence that cannot become true later. Alarm-worthy.
5830    Refuse(&'static str),
5831}
5832
5833/// Judge a vended Private-Channel key against our OWN folded state.
5834///
5835/// The Grant is the authority half and rides the owner-rooted control plane, so
5836/// it cannot be forged; the vend is only delivery. Acceptance therefore rests
5837/// entirely on what our own fold proves — a bundle can never introduce a channel
5838/// our control plane doesn't define, which is what closes the hidden-channel
5839/// injection class.
5840///
5841/// `community` must already be the held (self-certified) community: the caller
5842/// resolves it by `community_id`, so a bundle naming a community we're not in is
5843/// never judged here at all.
5844pub fn judge_channel_key_vend(
5845    community: &CommunityV2,
5846    roster: &crate::community::roles::CommunityRoles,
5847    channel_id: &ChannelId,
5848    epoch: Epoch,
5849    sender_hex: &str,
5850) -> VendVerdict {
5851    let me = match me_pk() {
5852        Ok(pk) => pk.to_hex(),
5853        Err(_) => return VendVerdict::Park("no active identity"),
5854    };
5855    let owner_hex = match community.owner() {
5856        Ok(o) => o.to_hex(),
5857        Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
5858    };
5859    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5860
5861    // (2) The channel must exist in OUR fold, and be private there. The bundle's
5862    // own claims are ignored: a vend may deliver a key, never define a channel.
5863    let Some(ch) = community.channel(channel_id) else {
5864        return VendVerdict::Park("channel not in our fold yet");
5865    };
5866    if !ch.private {
5867        // Never heals: our owner-rooted fold says this channel is public, so a
5868        // "private key" for it is a spoof, not a lagging view.
5869        return VendVerdict::Refuse("vend names a channel our fold says is public");
5870    }
5871
5872    // (5) Epoch sanity, BOTH directions. Below is superseded by the rotation that
5873    // produced our copy. Above matters more: the channel head is monotonic, so a
5874    // wildly-ahead epoch is not merely wrong, it is PERMANENT — every genuine
5875    // rotation afterwards lands at `head + 1`, is refused as stale, and the
5876    // channel dies for us with no heal path at all (not a rekey, not a re-grant,
5877    // not a refound). Rotations advance one epoch at a time, so a lead this large
5878    // is never a delivery we could place.
5879    if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
5880        return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
5881    }
5882    if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
5883        return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
5884    }
5885
5886    // (3) OUR fold must show US granted a role scoped to this channel. This is
5887    // the rule that kills the spoof class: an attacker cannot forge the Grant,
5888    // so they cannot make us accept a key for a channel we were never granted.
5889    if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
5890        return VendVerdict::Park("our grant for this channel has not folded yet");
5891    }
5892
5893    // (4) The vendor must be entitled too — they hold the real key, so a wrong
5894    // key from them costs isolation, never confidentiality.
5895    if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
5896        return VendVerdict::Park("vendor's entitlement has not folded yet");
5897    }
5898
5899    VendVerdict::Accept
5900}
5901
5902/// How long an unprovable parked vend is kept. Deliberately long: the fallback
5903/// heal is the channel's next rotation, which may never come.
5904const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
5905
5906/// How far above our channel head a vend may claim to be. Generous — a keyless
5907/// cursor can lag a busy channel by many rotations — but bounded, because the
5908/// head is monotonic and an over-advance can never be walked back.
5909const MAX_VEND_EPOCH_LEAD: u64 = 1024;
5910
5911/// Re-judge every parked key vend for this community and adopt the ones that now
5912/// pass. Runs after a control follow (the fold moved, so verdicts can change) and
5913/// on the boot sweep.
5914///
5915/// Returns the channels newly keyed up.
5916pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &std::sync::Arc<crate::db::Session>) -> Vec<ChannelId> {
5917    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5918    let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
5919        Ok(p) if !p.is_empty() => p,
5920        _ => return Vec::new(),
5921    };
5922    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5923    let mut adopted = Vec::new();
5924    let now = now_ms() / 1000;
5925    for p in parked {
5926        // Several candidates may name one channel (parking is open to any sender,
5927        // so a stranger can never suppress the entitled vendor's key by holding a
5928        // slot). Once one is seated the rest are moot.
5929        if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
5930            let _ = crate::db::community::drop_pending_channel_key(p.id);
5931            continue;
5932        }
5933        // A vend we were never able to prove is not kept forever: an admin who
5934        // adds then immediately removes someone leaves a row nothing will ever
5935        // discharge. Generous by design — the alternative heal (the channel's
5936        // next rotation) can be arbitrarily far away, so this is hygiene, not a
5937        // deadline.
5938        if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
5939            let _ = crate::db::community::drop_pending_channel_key(p.id);
5940            continue;
5941        }
5942        let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
5943            let _ = crate::db::community::drop_pending_channel_key(p.id);
5944            continue;
5945        };
5946        let channel_id = ChannelId(id_bytes);
5947        match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
5948            VendVerdict::Accept => {
5949                if !session.is_live() {
5950                    return adopted;
5951                }
5952                // First delivery vs rotation. A keyless channel must bypass the
5953                // monotonic guard: it sits at the epoch-0 cursor, and a peer that
5954                // mints born-private channels at epoch 0 vends that same epoch, so
5955                // `new > current` would refuse the only key on offer.
5956                let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
5957                let seated = if keyless {
5958                    crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
5959                } else {
5960                    crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
5961                };
5962                if let Err(e) = seated {
5963                    crate::log_warn!("v2: adopting a vended channel key failed: {e}");
5964                    continue;
5965                }
5966                // The key landed — every other candidate for this channel is moot.
5967                let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
5968                adopted.push(channel_id);
5969            }
5970            VendVerdict::Refuse(why) => {
5971                crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
5972                // Only THIS candidate — a sibling may still be the genuine vend.
5973                let _ = crate::db::community::drop_pending_channel_key(p.id);
5974            }
5975            // Quiet by design: the fold simply hasn't caught up.
5976            VendVerdict::Park(_) => {}
5977        }
5978    }
5979    adopted
5980}
5981
5982/// Grant `member` read access to a Private channel (CORD-03 "delivered on
5983/// grant"): publish a Grant adding the channel's access role, then vend the key
5984/// as a CORD-05 §6 Direct Invite whose bundle carries exactly the channels they
5985/// are now entitled to.
5986///
5987/// The Grant is the authority half and rides the owner-rooted control plane, so
5988/// it cannot be forged; the vend is only delivery. A recipient accepts the key
5989/// solely on the strength of their OWN fold showing this grant — the bundle can
5990/// never introduce a channel their control plane doesn't define.
5991pub async fn grant_channel_access<T: Transport + ?Sized>(
5992    transport: &T,
5993    community: &CommunityV2,
5994    channel_id: &ChannelId,
5995    member: &PublicKey,
5996) -> Result<(), String> {
5997    crate::db::scoped(async move {
5998        let my_pk = me_pk()?;
5999        let ch = community.channel(channel_id).ok_or("unknown channel")?;
6000        if !ch.private {
6001            return Err("channel is public — every member already reads it".to_string());
6002        }
6003        if ch.key.is_none() {
6004            return Err("we hold no key for this channel, so we cannot vend it".to_string());
6005        }
6006        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6007        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6008        let owner_hex = community.owner()?.to_hex();
6009        // A Grant REPLACES the member's role set, so the union it is built from must
6010        // be CURRENT: a stale local roster would silently strip every role this
6011        // client hasn't folded yet. Fetch the authority fresh rather than trusting
6012        // the cache, and merge the local view on top so a role we just published
6013        // ourselves (which the plane has but no fold has read back) survives too.
6014        let view = fetch_authority(transport, community).await;
6015        let mut roster = view.roles.clone();
6016        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6017        for r in cached.roles {
6018            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6019                roster.roles.push(r);
6020            }
6021        }
6022        for g in cached.grants {
6023            if !roster.grants.iter().any(|x| x.member == g.member) {
6024                roster.grants.push(g);
6025            }
6026        }
6027        let my_hex = my_pk.to_hex();
6028        let member_hex = member.to_hex();
6029        // Reader-gated by MANAGE_ROLES, like any Grant; narrowed to this channel so
6030        // a channel-scoped manager can run its own access list.
6031        if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6032            return Err("not authorized to manage this channel's access".to_string());
6033        }
6034        // Rank as well as the bit: the fold drops a Grant aimed at a peer or superior
6035        // (CORD-04 §2), so publishing one vends a key against a role set no reader
6036        // will honour. The owner is the exception — never a valid rank target, but a
6037        // legitimate recipient of a channel key an admin minted.
6038        if member_hex != owner_hex {
6039            if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6040                return Err("you do not outrank this member".to_string());
6041            }
6042            // A Grant replaces whole — a floored-but-unserved head would publish a
6043            // wipe. The owner holds no Grant (their authority is implicit), so there
6044            // is no head to insist on and nothing a first edition could erase.
6045            require_grant_head(community, &view, &member_hex)?;
6046        }
6047        // The channel's roles are ordered by AUTHORITY, so `.first()` is the most
6048        // privileged — granting read access must never hand out a per-channel
6049        // moderator role that happens to share the scope. Pick the permission-less
6050        // one: conferring read access is exactly what carries no authority.
6051        let role_id = roster
6052            .channel_roles(&chan_hex)
6053            .into_iter()
6054            .find(|r| r.permissions == crate::community::roles::Permissions::empty())
6055            .map(|r| r.role_id.clone())
6056            .ok_or("channel has no permission-less access role to grant")?;
6057
6058        let mut role_ids: Vec<String> = roster.roles_of(&member_hex).map(|r| r.role_id.clone()).collect();
6059        if !role_ids.contains(&role_id) {
6060            role_ids.push(role_id.clone());
6061        }
6062        // The access role is permission-less (never staff-making), but a KEPT role
6063        // can be — the fetched roster judges (CORD-04 §3).
6064        grant_roles_with_roster(transport, community, member, role_ids.clone(), &roster).await?;
6065        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
6066        // Settle the vend against the Grant we JUST published — the fold lags it.
6067        let bundle = bundle_of_with_overlay(
6068            community,
6069            BundleAudience::Member(*member),
6070            Some(my_pk),
6071            None,
6072            None,
6073            std::slice::from_ref(&role_id),
6074            &[],
6075        );
6076        let signer = crate::signer::active_signer()?;
6077        let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
6078        transport.publish(&wrap, &community.relays).await?;
6079        Ok(())
6080    })
6081    .await
6082}
6083
6084/// Revoke `member`'s read access to a Private channel (CORD-03 "rekeyed on
6085/// removal"): drop the channel's access role from their Grant, then rotate the
6086/// channel to its next epoch delivering the fresh key to everyone still
6087/// entitled (CORD-06). The revoked member keeps whatever history they already
6088/// read — a rekey protects the future, never the past.
6089pub async fn revoke_channel_access<T: Transport + ?Sized>(
6090    transport: &T,
6091    community: &CommunityV2,
6092    channel_id: &ChannelId,
6093    member: &PublicKey,
6094) -> Result<(), String> {
6095    crate::db::scoped(async move {
6096        let my_pk = me_pk()?;
6097        let ch = community.channel(channel_id).ok_or("unknown channel")?;
6098        if !ch.private {
6099            return Err("channel is public — there is no access to revoke".to_string());
6100        }
6101        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6102        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6103        let owner_hex = community.owner()?.to_hex();
6104        // Same replace-not-merge hazard as the grant: the retained set must be built
6105        // from a CURRENT roster or this revoke strips roles we simply hadn't folded.
6106        let view = fetch_authority(transport, community).await;
6107        let mut roster = view.roles.clone();
6108        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6109        for r in cached.roles {
6110            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6111                roster.roles.push(r);
6112            }
6113        }
6114        for g in cached.grants {
6115            if !roster.grants.iter().any(|x| x.member == g.member) {
6116                roster.grants.push(g);
6117            }
6118        }
6119        let my_hex = my_pk.to_hex();
6120        let member_hex = member.to_hex();
6121        if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6122            return Err("not authorized to manage this channel's access".to_string());
6123        }
6124        if *member == community.owner()? {
6125            return Err("the owner is supreme and cannot be removed".to_string());
6126        }
6127        // Rank as well as the bit, and it matters more here than on the grant side:
6128        // readers drop the Grant, but the channel rotation below is gated on the
6129        // citation alone, so an unauthorized revoke still severs its target.
6130        if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6131            return Err("you do not outrank this member".to_string());
6132        }
6133        // A Grant replaces whole — a floored-but-unserved head would publish a wipe.
6134        require_grant_head(community, &view, &member_hex)?;
6135        let access_ids = roster.channel_role_ids(&chan_hex);
6136        // Without the access list this revoke is a no-op that still ROTATES, and the
6137        // rotation's recipient filter would match nobody — cutting off every
6138        // legitimately entitled member. Refuse rather than mass-evict.
6139        if access_ids.is_empty() {
6140            return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
6141        }
6142        let remaining: Vec<String> = roster
6143            .roles_of(&member_hex)
6144            .map(|r| r.role_id.clone())
6145            .filter(|id| !access_ids.contains(id))
6146            .collect();
6147        grant_roles_with_roster(transport, community, member, remaining.clone(), &roster).await?;
6148        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
6149        // Rotate so the removal actually severs them (CORD-06 §1). The revoked
6150        // member is excluded from the recipient set by the overlay, since the fold
6151        // has not yet caught the Grant we just published.
6152        rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, std::slice::from_ref(member)).await
6153    })
6154    .await
6155}
6156
6157/// Rotate one Private channel to its next epoch, delivering the fresh key to
6158/// everyone entitled EXCEPT `removed` (CORD-06 §1 single-channel rekey).
6159///
6160/// `roster` must be the caller's CURRENT view (fetched, not the local cache):
6161/// the recipient set is built from it, so a cached roster silently drops every
6162/// member granted since this client last folded — they keep a dead key with no
6163/// heal path. `access_ids` is that roster's access-role set for this channel;
6164/// `removed` is excluded explicitly, since the revoking Grant was published
6165/// moments ago and no fold has caught it. A batch ban excludes its whole wave
6166/// in ONE rotation — that is why this takes a slice.
6167async fn rekey_channel_excluding<T: Transport + ?Sized>(
6168    transport: &T,
6169    community: &CommunityV2,
6170    channel_id: &ChannelId,
6171    roster: &crate::community::roles::CommunityRoles,
6172    access_ids: &[String],
6173    removed: &[PublicKey],
6174) -> Result<(), String> {
6175    crate::db::scoped(async move {
6176        // Whole-row save below — serialize with the follow worker (see create_*_channel).
6177        let lock = super::realtime::follow_lock(community.id());
6178        let _guard = lock.lock().await;
6179        let signer = crate::signer::active_signer()?;
6180        let my_pk = me_pk()?;
6181        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6182        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6183        let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
6184        let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
6185        let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
6186        let owner = community.owner()?;
6187        let owner_hex = owner.to_hex();
6188
6189        // Everyone still entitled: the owner (always), me (the rotator must be able
6190        // to read what it rekeys), and every member the roster shows holding an
6191        // access role — minus the removals.
6192        let removed_hexes: std::collections::HashSet<String> = removed.iter().map(|p| p.to_hex()).collect();
6193        let mut recipients: Vec<PublicKey> = vec![my_pk];
6194        if owner != my_pk {
6195            recipients.push(owner);
6196        }
6197        for g in &roster.grants {
6198            if removed_hexes.contains(&g.member) || g.member == owner_hex {
6199                continue;
6200            }
6201            if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
6202                continue;
6203            }
6204            if let Ok(pk) = PublicKey::parse(&g.member) {
6205                if !recipients.contains(&pk) {
6206                    recipients.push(pk);
6207                }
6208            }
6209        }
6210        // Mint-or-reuse keyed by (channel, next epoch) so a retry after a partial
6211        // publish re-uses the same key instead of forking the epoch.
6212        let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
6213        let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
6214        let mut blobs = Vec::with_capacity(recipients.len());
6215        for r in &recipients {
6216            blobs.push(
6217                rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
6218                    .await
6219                    .map_err(|e| e.to_string())?,
6220            );
6221        }
6222        let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
6223        let at_secs = now_ms() / 1000;
6224        let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
6225            .await
6226            .map_err(|e| e.to_string())?;
6227        for c in &chunks {
6228            transport.publish_durable(c, &community.relays).await?;
6229        }
6230        if crate::db::community::community_protocol(community.id())?.is_none() {
6231            return Err("community removed during channel rekey".to_string());
6232        }
6233        // Adopt locally + archive, so our own history reads across the rotation.
6234        crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6235        crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6236
6237        // §7 Rotator duty: reseal the Pin List under the NEW key. Without this,
6238        // members who join at this epoch hold no old key and the channel's pins
6239        // read as sealed-dark for them forever. The rotator is uniquely placed:
6240        // it provably reads the old seal (it held the old key) and mints the new
6241        // one. Best-effort — a failed reseal never fails the rotation, and any
6242        // curator's next edit heals the same way.
6243        {
6244            let mut rotated = community.clone();
6245            if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
6246                c.key = Some(new_key);
6247                c.epoch = new_epoch;
6248            }
6249            match read_channel_pins(&rotated, channel_id) {
6250                Ok(read) if !read.sealed && !read.pins.is_empty() => {
6251                    let entries: Vec<super::pins::PinEntry> =
6252                        read.pins.iter().map(|p| p.entry.clone()).collect();
6253                    if let Some(ch2) = rotated.channel(channel_id).cloned() {
6254                        if let Err(e) = publish_pin_list(transport, &rotated, &ch2, &entries).await {
6255                            crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
6256                        } else {
6257                            crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
6258                        }
6259                    }
6260                }
6261                Ok(read) if read.sealed => {
6262                    crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
6263                }
6264                _ => {}
6265            }
6266        }
6267        Ok(())
6268    })
6269    .await
6270}
6271
6272/// Rotate every private channel a just-banned member could read (CORD-06 §1 applied
6273/// per channel). A Public-community Ban skips the Refounding (CORD-05 §5), but the
6274/// banlist and grant strip alone leave the member holding each private channel's
6275/// CURRENT epoch key — rotation is the only read severance.
6276///
6277/// Takes the whole banned wave: a channel several targets could read rotates ONCE,
6278/// excluding them all. Per-target rotation would mint N epochs for one ban wave and
6279/// churn every legitimate reader N times.
6280///
6281/// Each target carries its role set as captured before the strip: the fold may or
6282/// may not have caught the strip yet, and the `with` overlay makes the entitlement
6283/// judgment independent of that timing.
6284///
6285/// Offer-side mirror of the reader's `channel_rotator_ok`: a channel rotation is
6286/// honored only from `MANAGE_CHANNELS` holders, so a BAN-only moderator must not
6287/// publish one — locally adopting an epoch every reader rejects forks the channel.
6288///
6289/// Best-effort per channel (one failure must not leave the others unrotated);
6290/// returns how many channels rotated, or the joined failures.
6291pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
6292    transport: &T,
6293    community: &CommunityV2,
6294    targets: &[(PublicKey, Vec<String>)],
6295) -> Result<usize, String> {
6296    crate::db::scoped(async move {
6297        let my_pk = me_pk()?;
6298        ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
6299        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6300        // Recipients need the CURRENT entitlement view — fetched, then merged over the
6301        // cache so entitlements we published ourselves survive a lagging fold (the same
6302        // two-strand merge as `revoke_channel_access`).
6303        let mut roster = fetch_authority(transport, community).await.roles;
6304        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6305        for r in cached.roles {
6306            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6307                roster.roles.push(r);
6308            }
6309        }
6310        for g in cached.grants {
6311            if !roster.grants.iter().any(|x| x.member == g.member) {
6312                roster.grants.push(g);
6313            }
6314        }
6315        let owner_hex = community.owner().ok().map(|o| o.to_hex());
6316        let mut rotated = 0usize;
6317        let mut failures: Vec<String> = Vec::new();
6318        for ch in &community.channels {
6319            if !ch.private || ch.key.is_none() {
6320                continue;
6321            }
6322            let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6323            // Everyone in the wave who could read this channel, cut in ONE rotation.
6324            let excluded: Vec<PublicKey> = targets
6325                .iter()
6326                .filter(|(pk, stripped)| roster.is_entitled(owner_hex.as_deref(), &pk.to_hex(), &chan_hex, stripped, &[]))
6327                .map(|(pk, _)| *pk)
6328                .collect();
6329            if excluded.is_empty() {
6330                continue;
6331            }
6332            let access_ids = roster.channel_role_ids(&chan_hex);
6333            // Entitlement without an access-role set can't happen for a non-owner, and
6334            // an empty set would make the rotation's recipient filter mass-evict.
6335            if access_ids.is_empty() {
6336                continue;
6337            }
6338            // Reload per iteration: each rotation advances the held document, and a
6339            // stale struct would mint a colliding channel epoch on the next pass.
6340            let held = match crate::db::community::load_community_v2(community.id()) {
6341                Ok(Some(c)) => c,
6342                _ => return Err("community gone during ban severance".to_string()),
6343            };
6344            match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, &excluded).await {
6345                Ok(()) => rotated += 1,
6346                Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
6347            }
6348        }
6349        if failures.is_empty() {
6350            Ok(rotated)
6351        } else {
6352            Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
6353        }
6354    })
6355    .await
6356}
6357
6358/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
6359/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
6360pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
6361    crate::db::scoped(async move {
6362        // Whole-row save below — serialize with the follow worker (see create_*_channel).
6363        let lock = super::realtime::follow_lock(community.id());
6364        let _guard = lock.lock().await;
6365        let my_pk = me_pk()?;
6366        ensure_channel_manager(community, &my_pk)?;
6367        // The tombstone carries the FULL held document (deleted flag set): a strict
6368        // reader treats an edition as the entity, so even a deletion must not strip
6369        // fields it didn't touch (CORD-02 §6).
6370        let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
6371            name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
6372        });
6373        meta.deleted = Some(true);
6374        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6375        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6376        let mut updated = community.clone();
6377        updated.channels.retain(|c| c.id.0 != channel_id.0);
6378        crate::db::community::save_community_v2(&updated)?;
6379        Ok(())
6380    })
6381    .await
6382}
6383
6384// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
6385
6386/// Re-fold this community's Control Plane and apply the current metadata +
6387/// **public** channel set to the held community, persisting any change. Called
6388/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
6389/// edited description) so a long-running bot tracks the community mid-session
6390/// instead of freezing at its join-time view.
6391///
6392/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
6393/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
6394/// edition is eligible only if its signer CURRENTLY holds the entity's management
6395/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
6396/// a demoted one's drop. The owner is supreme, proven by the self-certifying
6397/// community_id (no network trust).
6398///
6399/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
6400/// delivered over the rekey plane (or an invite bundle), never derivable from a
6401/// control edition alone. A new Private channel therefore surfaces only once
6402/// [`follow_rekeys`] delivers its key. Public channels derive from the
6403/// community_root, so they fold in directly.
6404///
6405/// Returns the updated community iff something changed (so the caller can skip a
6406/// redundant re-subscribe + refresh notification).
6407/// What a control fold moved. `updated` is the community DOCUMENT (metadata,
6408/// channels, adopted control root); `authority_changed` is the roster/banlist/
6409/// registry — which live in their own columns and so are invisible in `updated`.
6410///
6411/// The distinction is load-bearing: the rekey walk's authority gates read exactly
6412/// the state `authority_changed` covers, so a caller that keys its retry off
6413/// `updated` alone will refuse a rotation on pass 1, silently fix the roster on
6414/// pass 2, and never look again. That is a permanent strand — and a strand is a
6415/// fork to everyone else.
6416#[derive(Debug, Default)]
6417pub struct ControlFollow {
6418    pub updated: Option<CommunityV2>,
6419    pub authority_changed: bool,
6420}
6421
6422impl ControlFollow {
6423    /// Did anything at all move? Either half warrants another rekey walk.
6424    pub fn moved(&self) -> bool {
6425        self.updated.is_some() || self.authority_changed
6426    }
6427}
6428
6429pub async fn follow_control<T: Transport + ?Sized>(
6430    transport: &T,
6431    community: &CommunityV2,
6432) -> Result<ControlFollow, String> {
6433    crate::db::scoped(async move {
6434        community.owner()?; // fail fast if the community is somehow unproven.
6435        let control = control::ControlPlane::of(community);
6436        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6437
6438        // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
6439        // under a prior epoch is excluded, so that entity auto-bootstraps after a
6440        // Refounding (Armada accepts a compacted head across a dangling prev — matched).
6441        // A read error FAILS CLOSED: an empty map would silently re-open the rollback
6442        // window the floor exists to shut.
6443        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
6444            .into_iter()
6445            .filter(|(_, f)| f.0 == community.root_epoch.0)
6446            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
6447            .collect();
6448
6449        // Newest window first; page OLDER only while a tracking entity is gapped (its
6450        // floor link evicted from the window — H1/M8 refetch), bounded like the join
6451        // verifier. A withholding relay still converges to fail-closed after the cap.
6452        let mut editions: Vec<ParsedEdition> = Vec::new();
6453        let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
6454        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
6455        let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
6456        let mut authority = AuthoritySet::owner_only();
6457        // Whether this round gave up with editions still unread. The follow is
6458        // procedural by design — process what arrives, converge with everyone else —
6459        // so a short read never blocks reading, writing or epoch adoption. It only
6460        // withholds the ROSTER cache below: caching a partial authority as this
6461        // device's baseline is the one step that outlives the round.
6462        let mut truncated = true;
6463        let mut pages_read = 0usize;
6464        // Quorum answers as soon as a majority does, so a fast subset that happens not
6465        // to hold an older edition is indistinguishable from that edition not existing —
6466        // and the same subset answers every boot, so the retain never lifts and the
6467        // roster cache freezes with no way back. This fold draws an ABSENCE verdict
6468        // (`stored_complete`, `truncated`), and CORD-04 §5 makes a gap fail closed FOR
6469        // THAT ENTITY and refetch. So escalate: read at Quorum, and if anything we
6470        // already hold is still unaccounted for, read again at Full before concluding.
6471        // Healthy folds never pay for the second tier.
6472        for tier in [
6473            crate::community::transport::Evidence::Quorum,
6474            crate::community::transport::Evidence::Full,
6475        ] {
6476            let mut until: Option<u64> = None;
6477            let mut oldest: Option<u64> = None;
6478            truncated = true;
6479            for _ in 0..FOLLOW_MAX_PAGES {
6480                pages_read += 1;
6481                let query = Query {
6482                    kinds: vec![stream::KIND_WRAP],
6483                    authors: vec![control.pk_hex()],
6484                    until,
6485                    limit: Some(FOLLOW_PAGE),
6486                    evidence: tier,
6487                    ..Default::default()
6488                };
6489                let wraps = transport.fetch(&query, &community.relays).await?;
6490                // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
6491                // at a page boundary); the wrap-id dedup makes re-served boundary events
6492                // free, and a page with nothing new means the relay is exhausted.
6493                let mut fresh = 0usize;
6494                for w in &wraps {
6495                    if !seen_wraps.insert(w.id) {
6496                        continue;
6497                    }
6498                    fresh += 1;
6499                    let at = w.created_at.as_secs();
6500                    if oldest.is_none_or(|o| at < o) {
6501                        oldest = Some(at);
6502                    }
6503                    // Open + seal-verify every edition; authority is resolved by the roster
6504                    // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
6505                    if let Ok((ed, _)) = control.open(w) {
6506                        if seen.insert(ed.inner_id) {
6507                            editions.push(ed);
6508                        }
6509                    }
6510                }
6511                // Roster first (roles/grants/banlist → authorized set), then the authority-
6512                // gated metadata/channel fold over the same edition set.
6513                authority = fold_authority(community, &editions, &floors);
6514                fold = apply_control_fold(community, &editions, &floors, &authority);
6515                // "Nothing is gapped" is NOT "I have read enough".
6516                //
6517                // An entity we track whose editions are entirely absent from the window
6518                // folds away silently — absence dangles no link, so it raises no gap. The
6519                // loop therefore declared the very first page coherent and stopped, and a
6520                // community whose grants sit older than one page could never re-derive its
6521                // own roster: every fold offered zero grants, and only the completeness
6522                // guard retaining the cache kept the admin list alive at all. That made the
6523                // cache the sole surviving copy of the roster rather than a cache.
6524                //
6525                // So keep paging while anything we already know about is still unaccounted
6526                // for. This only ever reads MORE; it cannot lose data, and it terminates on
6527                // the page cap or an exhausted plane exactly as before.
6528                let heads_now: std::collections::HashSet<&str> =
6529                    authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6530                // A FAILED read must not read as "I track nothing" — that makes
6531                // "everything is accounted for" trivially true and stops the walk at the
6532                // first page, which is the exact failure this loop exists to prevent.
6533                // Fail toward reading MORE.
6534                let all_accounted = match crate::db::community::get_community_roles(&cid_hex) {
6535                    Ok(known) => {
6536                        // A roster we have not built yet accounts for nothing, so
6537                        // "everything I track is present" is vacuously TRUE and stops a
6538                        // COLD device at the first page — caching whatever the newest
6539                        // window happened to hold as its whole baseline, and losing every
6540                        // grant older than one page. The early break is an optimisation
6541                        // for a client that already knows what it is looking for.
6542                        (!known.roles.is_empty() || !known.grants.is_empty())
6543                            && known.roles.iter().all(|r| heads_now.contains(r.role_id.as_str()))
6544                            && known.grants.iter().all(|g| {
6545                                crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6546                                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6547                                    heads_now.contains(eid.as_str())
6548                                })
6549                            })
6550                    }
6551                    Err(e) => {
6552                        crate::log_warn!("[v2:control {}] roster read failed mid-page ({e}) — paging on rather than assuming nothing is tracked", &cid_hex[..8.min(cid_hex.len())]);
6553                        false
6554                    }
6555                };
6556                if !(fold.gapped || authority.gapped) && all_accounted {
6557                    truncated = false; // coherent AND complete for everything we track
6558                    break;
6559                }
6560                if fresh == 0 {
6561                    // A FULL page with nothing new is a same-second wall no `until` steps
6562                    // past, so older editions stay unreachable; a short page is the end
6563                    // of the plane, and a gap in THAT is the relay withholding, not us
6564                    // giving up early.
6565                    truncated = wraps.len() >= FOLLOW_PAGE;
6566                    break;
6567                }
6568                until = oldest;
6569            }
6570
6571            if tier == crate::community::transport::Evidence::Full {
6572                break;
6573            }
6574            let stored_so_far = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6575            let heads_so_far: std::collections::HashSet<&str> =
6576                authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6577            let accounted = stored_so_far.roles.iter().all(|r| !floors.contains_key(&r.role_id) || heads_so_far.contains(r.role_id.as_str()))
6578                && stored_so_far.grants.iter().all(|g| {
6579                    crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6580                        let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6581                        !floors.contains_key(&eid) || heads_so_far.contains(eid.as_str())
6582                    })
6583                });
6584            if accounted {
6585                break;
6586            }
6587            crate::log_debug!(
6588                "[v2:control {}] {} stored entit(ies) unaccounted after a Quorum read — re-reading at Full before drawing an absence verdict",
6589                &cid_hex[..8.min(cid_hex.len())],
6590                stored_so_far.roles.len() + stored_so_far.grants.len()
6591            );
6592        }
6593
6594        // The fetches straddled awaits; a swap since the guard was captured must not
6595        // write account A's control state into B.
6596        // A leave/delete raced this follow: writing now would resurrect the community
6597        // row and orphan floor rows past delete_community's wipe.
6598        if crate::db::community::community_protocol(community.id())?.is_none() {
6599            return Ok(ControlFollow::default());
6600        }
6601        // Persist advanced floors BEFORE the state save (a failed floor write must not
6602        // let saved state outrun its floor), stamping the epoch this fold ran under —
6603        // not the row's write-time value, which a concurrent re-founding can bump. Both
6604        // the metadata/channel heads and the roster/banlist heads advance their floors;
6605        // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
6606        for h in fold.heads.iter().chain(authority.heads.iter()) {
6607            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)?;
6608            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)?;
6609        }
6610        // Adopt the staff write key a promotion delivered inside MY OWN Grant
6611        // (CORD-04 §3): decrypt the control_wrap under the granter↔me pairwise key
6612        // and record the secret. Verified fail-closed inside — the epoch must be
6613        // the one I'm on and the secret must derive to exactly the control_pk I
6614        // hold — so a stale wrap (compaction re-wraps a Grant head verbatim across
6615        // Refoundings) or a forged one is dropped, never adopted.
6616        let adopted_control_root = adopt_my_control_wrap(community, &editions).await;
6617        // The adopt can await a remote signer (bunker NIP-44 round-trip) — re-validate
6618        // before the per-account writes below.
6619        // Persist the authorized banlist content (retained/withholding folds carry None,
6620        // so the stored banlist is left intact — an anti-roster never silently un-bans).
6621        let mut authority_changed = false;
6622        // Ban marks MERGE (never replace): they must outlive both the ban and this window,
6623        // so a later un-ban can't resurrect a pre-ban Join. Persisted even when the banlist
6624        // itself was retained — the history is what the suppression reads.
6625        let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
6626        if let Some((banned, version)) = &authority.banlist_persist {
6627            let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6628            crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
6629            let mut after = banned.clone();
6630            before.sort();
6631            after.sort();
6632            authority_changed |= before != after;
6633        }
6634        // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
6635        // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
6636        // fetch path: only an aggregate built from roster editions at least as new as
6637        // the stored one may replace it — a withholding relay serving NO roster
6638        // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
6639        // and that must RETAIN the stored roster, never wipe standing.
6640        let newest_roster_at: i64 = editions
6641            .iter()
6642            .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
6643            .map(|e| e.created_at as i64)
6644            .max()
6645            .unwrap_or(0);
6646        // Completeness gate: the `gapped` flag only covers entities present in the window.
6647        // A role/grant floored on this device but with ZERO editions fetched (aged out of
6648        // the paging reach) folds absent yet raises no gap — persisting would silently drop
6649        // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
6650        // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
6651        let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6652        let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6653        let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
6654            && stored.grants.iter().all(|g| {
6655                crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6656                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6657                    !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
6658                })
6659            });
6660        // `truncated` covers the case the other three can't: a COLD device (no floors,
6661        // no stored roster) folding under a plane a member has inflated past the pager.
6662        // `stored_complete` is trivially true with nothing stored, so without this the
6663        // first sync would cache a partial authority as its own baseline.
6664        let stored_at = crate::db::community::get_community_roles_at(&cid_hex)?;
6665        // `stored_at == 0` is a roster with NO plane provenance: the optimistic local
6666        // merge every creator writes (`merge_local_roster`) so a just-made role works
6667        // before its edition folds. It is a guess about the plane, not evidence about
6668        // it, so it must never out-vote a coherent fold — and it was: the seed names
6669        // entities the plane no longer heads, `stored_complete` goes false forever, and
6670        // the roster can never be replaced. A community's OWNER is the likeliest holder
6671        // of such a seed, so the wedge lands on exactly the account whose rotations
6672        // everyone else is waiting to adopt.
6673        let seed_only = stored_at == 0;
6674        // A floor that can NEVER be satisfied must not veto the roster forever.
6675        //
6676        // `stored_complete` retains the cache when a stored entity is floored but
6677        // folded no head, because a withholding relay looks exactly like that. The
6678        // trouble is that an entity can legitimately stop producing a head for good:
6679        // its author was demoted, so `select_authorized` drops their editions; or the
6680        // entity was compacted away. The veto then holds forever, the roster can never
6681        // be replaced, and a client with a stale roster refuses every rotation from an
6682        // admin promoted since — which is a fork, arrived at silently.
6683        //
6684        // So when the read was COHERENT — the whole plane paged, nothing gapped, and
6685        // the fetch itself is quorum-backed — an entity that produced no head is
6686        // genuinely not there as far as the reachable relay set can tell. Forget its
6687        // floor and let the roster move. A read that was truncated or gapped proves
6688        // nothing and still retains, which is the withholding case the guard exists
6689        // for. My OWN grant is never pruned: losing my own standing on a bad read is
6690        // the one mistake with no path back.
6691        // NO FLOOR PRUNING. An attempt at it evicted thirteen real admins from a live
6692        // community: their grant editions were merely outside the fetched window, and
6693        // "the plane is serving newer editions than mine" is not evidence that a
6694        // specific entity is gone. A frozen roster is a fork risk; a WRONG roster is a
6695        // fork plus silent loss of everyone's standing, which is strictly worse. Until
6696        // there is per-entity proof of absence, absence stays unproven.
6697        // NOT gated on `authority.gapped`. That flag is a global OR across every
6698        // entity — its own declaration calls it the per-entity flag that "drives
6699        // older-paging" — so one unresolvable entity anywhere vetoed the whole roster
6700        // cache, permanently once the plane is exhausted and more paging cannot help.
6701        // CORD-04 §5 says a gap fails closed FOR THAT ENTITY, suspending and refetching
6702        // it, and that already happens: a gapped entity produces no head and so is
6703        // simply absent from the fold. Freezing every other entity behind it is the
6704        // over-reach, and it is what left live communities unable to re-derive rosters
6705        // they could otherwise read perfectly.
6706        //
6707        // The downgrade defence is `stored_complete`, which is untouched: nothing we
6708        // already know can silently vanish. An entity we have never held cannot be
6709        // stripped from us by its absence.
6710        if !truncated && (stored_complete || seed_only) && newest_roster_at >= stored_at {
6711            authority_changed |= stored != authority.roles;
6712            crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
6713        } else {
6714            // Loud, because the failure mode is silent and total: the roster is what
6715            // authorizes every rotation, so a client that can never re-cache it is
6716            // permanently deaf to that admin's re-foundings — and the only symptom is
6717            // an epoch that never advances.
6718            // Name the BLOCKERS and what the fold actually found. Without these the
6719            // warning says a decision was made but not on what evidence, which is how
6720            // a wrong roster stays indistinguishable from a cautious one.
6721            let mut blockers: Vec<String> = Vec::new();
6722            for r in &stored.roles {
6723                if floors.contains_key(&r.role_id) && !head_ents.contains(r.role_id.as_str()) {
6724                    blockers.push(format!("role:{}", &r.role_id[..8.min(r.role_id.len())]));
6725                }
6726            }
6727            for g in &stored.grants {
6728                if let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) {
6729                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6730                    if floors.contains_key(&eid) && !head_ents.contains(eid.as_str()) {
6731                        blockers.push(format!("grant:{}", &g.member[..8.min(g.member.len())]));
6732                    }
6733                }
6734            }
6735            // Per-entity fate of every roster edition in the window. "Blocked" only
6736            // says which stored entity produced no head; this says WHY — whether its
6737            // editions were absent, or present and dropped, and by whom they were
6738            // authored. Guessing between those three cost a day.
6739            {
6740                use std::collections::BTreeMap;
6741                let mut fate: BTreeMap<String, (u64, u64, String, &str)> = BTreeMap::new();
6742                for e in &editions {
6743                    if e.vsk != vsk::ROLE && e.vsk != vsk::GRANT {
6744                        continue;
6745                    }
6746                    let kind = if e.vsk == vsk::ROLE { "role" } else { "grant" };
6747                    let eid = crate::simd::hex::bytes_to_hex_32(&e.entity_id);
6748                    let slot = fate.entry(eid).or_insert((0, 0, String::new(), kind));
6749                    slot.0 += 1;
6750                    if e.version > slot.1 {
6751                        slot.1 = e.version;
6752                        slot.2 = e.author.to_hex()[..8].to_string();
6753                        slot.3 = kind;
6754                    }
6755                }
6756                let head_now: std::collections::HashSet<&str> =
6757                    authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6758                let rows: Vec<String> = fate
6759                    .iter()
6760                    .map(|(eid, (n, v, author, kind))| {
6761                        format!(
6762                            "{}[{kind}]:{}ed/v{}/by{}{}{}",
6763                            &eid[..8], n, v, author,
6764                            if head_now.contains(eid.as_str()) {
6765                                "/HEAD".to_string()
6766                            } else {
6767                                AUTHORITY_REFUSALS
6768                                    .with(|c| c.borrow().get(eid.as_str()).copied())
6769                                    .map(|r| {
6770                                        let extra = MISSING_ROLE_REFS
6771                                            .with(|m| m.borrow().get(eid.as_str()).cloned())
6772                                            .map(|ids| format!(" wants:{ids}"))
6773                                            .unwrap_or_default();
6774                                        format!("/DROPPED({r}{extra})")
6775                                    })
6776                                    .unwrap_or_else(|| "/dropped(no candidate survived the fold)".into())
6777                            },
6778                            floors.get(eid).map(|f| format!("/floor@v{}", f.0)).unwrap_or_default(),
6779                        )
6780                    })
6781                    .collect();
6782                // The OWNER is the root of all authority: every other author needs a
6783                // grant that traces back to them. If no edition here is owner-authored
6784                // the chain cannot bootstrap at all, and that is a different bug from
6785                // any individual entity being rejected.
6786                crate::log_warn!(
6787                    "[v2:control {}] fold accepted roles: [{}]",
6788                    &cid_hex[..8.min(cid_hex.len())],
6789                    authority.roles.roles.iter().map(|r| format!("{}@pos{}", &r.role_id[..8.min(r.role_id.len())], r.position)).collect::<Vec<_>>().join(" ")
6790                );
6791                crate::log_warn!(
6792                    "[v2:control {}] owner={} — roster editions in window: [{}]",
6793                    &cid_hex[..8.min(cid_hex.len())],
6794                    community.owner().map(|o| o.to_hex()[..8].to_string()).unwrap_or_else(|_| "UNPROVEN".into()),
6795                    rows.join(" ")
6796                );
6797            }
6798            crate::log_warn!(
6799                "[v2:control {}] roster NOT cached (truncated={} authority_gapped={} doc_gapped={} stored_complete={} newest_roster_at={} stored_at={}) — stored {}r/{}g, fold offers {}r/{}g from {} edition(s) over {} page(s), blocked by [{}]",
6800                &cid_hex[..8.min(cid_hex.len())], truncated, authority.gapped, fold.gapped, stored_complete, newest_roster_at, stored_at,
6801                stored.roles.len(), stored.grants.len(), authority.roles.roles.len(), authority.roles.grants.len(),
6802                editions.len(), pages_read,
6803                blockers.join(", ")
6804            );
6805        }
6806        // Cache the folded invite Registry so Public/Private stays a sync LOCAL read
6807        // (v1 parity — `invite_registry` is the column every caller reads). Gated like
6808        // the roster: a truncated or gapped window folds an empty registry out of mere
6809        // absence, and persisting that under-states Public — the unsafe direction, since
6810        // it leaves a live link open behind a ban.
6811        if !truncated && !authority.gapped && !fold.gapped {
6812            if let Ok(owner) = community.owner() {
6813                let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
6814                let live = flatten_link_sets(&sets);
6815                let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
6816                before.sort();
6817                if before != live {
6818                    crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
6819                    authority_changed = true;
6820                }
6821                // The per-creator split drives "X has N active invite links" and the
6822                // first-link-flips-Public confirm; it lives in its own table.
6823                crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
6824            }
6825        }
6826        // Folded Pin List heads (CORD-04 §7): raw content per channel. The write
6827        // itself is monotonic on version (atomic in the statement), so a stale
6828        // window racing a publish echo can never regress a newer held head.
6829        for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
6830            match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
6831                Ok(true) => {
6832                    crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
6833                    crate::emit_event(
6834                        "community_pins_updated",
6835                        &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
6836                    );
6837                    note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
6838                }
6839                Ok(false) => {}
6840                Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
6841            }
6842        }
6843        // Roster/banlist moves are invisible in the returned community (they live in
6844        // their own columns), so callers that key a refresh off `updated` would never
6845        // repaint a promote/demote/ban. Announce from the single fold point — it covers
6846        // realtime, boot catch-up and manual sync alike.
6847        if authority_changed {
6848            crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
6849        }
6850        // Owner-side silent Admin widening (PIN_MESSAGES): after the roster has
6851        // folded, so the check reads the settled roles. Once per community per
6852        // process — the fold runs constantly and the upgrade is a one-shot.
6853        {
6854            static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
6855            let first = UPGRADED
6856                .lock()
6857                .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
6858                .unwrap_or(false);
6859            if first {
6860                let _ = upgrade_admin_role_pin_bit(transport, community).await;
6861            }
6862        }
6863        // The pins/upgrade tail awaited network I/O — re-validate before the vault save.
6864        // An adopted staff key rides whatever gets saved (the fold's update, or the
6865        // otherwise-unchanged community) — the vault write that makes it durable.
6866        let mut updated = fold.updated;
6867        if let Some(cr) = adopted_control_root {
6868            let mut u = updated.take().unwrap_or_else(|| community.clone());
6869            u.control_root = Some(cr);
6870            updated = Some(u);
6871        }
6872        match updated {
6873            Some(u) => {
6874                crate::db::community::save_community_v2(&u)?;
6875                Ok(ControlFollow { updated: Some(u), authority_changed })
6876            }
6877            None => Ok(ControlFollow { updated: None, authority_changed }),
6878        }
6879    })
6880    .await
6881}
6882
6883/// Adopt the `control_root` a staff-making Grant delivered to ME (CORD-04 §3).
6884///
6885/// Nothing to adopt on a legacy epoch (no write key exists) or when the secret
6886/// is already held. Candidate editions are MY grant coordinate's, newest
6887/// version first; authority-gating them is unnecessary for adoption — the
6888/// derive check IS the gate (only the true secret derives to the held
6889/// `control_pk`, so the worst a forged edition can do is fail it). The epoch
6890/// rides inside the ciphertext because staleness is structural: compaction
6891/// re-wraps a Grant head verbatim across Refoundings, and staff crossing a
6892/// rotation get the new secret in their 136-byte base blob instead.
6893async fn adopt_my_control_wrap(community: &CommunityV2, editions: &[ParsedEdition]) -> Option<[u8; 32]> {
6894    use nostr_sdk::prelude::AsyncNip44;
6895    let held_pk = community.control_pk?;
6896    if community.control_root.is_some() {
6897        return None;
6898    }
6899    let my_pk = me_pk().ok()?;
6900    let signer = crate::signer::active_signer().ok()?;
6901    let my_eid = super::derive::grant_locator(community.id(), &my_pk.to_bytes());
6902    let mut candidates: Vec<&ParsedEdition> = editions
6903        .iter()
6904        .filter(|e| e.vsk == vsk::GRANT && e.entity_id == my_eid)
6905        .collect();
6906    candidates.sort_by(|a, b| b.version.cmp(&a.version));
6907    for ed in candidates {
6908        let Some(wrap_b64) = super::roles::parse_grant_control_wrap(&ed.content) else { continue };
6909        // The granter is the edition's sealed author — the pairwise key's other
6910        // half (one ECDH either side can compute; bunker accounts included).
6911        let Ok(plain_b64) = signer.nip44_decrypt_async(&ed.author, &wrap_b64).await else { continue };
6912        let Ok(plain) = base64_simd::STANDARD.decode_to_vec(plain_b64.as_bytes()) else { continue };
6913        let Ok((epoch, root)) = super::rekey::parse_control_wrap(&plain) else { continue };
6914        if epoch != community.root_epoch {
6915            continue;
6916        }
6917        if super::derive::control_signer_group_key(&root, community.id(), epoch).pk() != held_pk {
6918            continue;
6919        }
6920        crate::log_info!("[v2] adopted the staff write key from my Grant (epoch {})", epoch.0);
6921        return Some(root);
6922    }
6923    None
6924}
6925
6926/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
6927/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
6928///
6929/// Nearly free to raise: both follow loops exit the moment the fold stops being
6930/// gapped, so the cap only binds when something is genuinely missing — exactly
6931/// when paging further is what's wanted. The old ceiling of 4 (~2k editions) sat
6932/// under a plane that 100 roles + 400 grants already outgrows before counting
6933/// superseded versions, which accumulate until a compaction retires them.
6934/// One walk's hop bound. A backlog deeper than this converges across passes,
6935/// because every adopted hop is checkpointed before the next is attempted.
6936const MAX_WALK_STEPS: usize = 128;
6937const FOLLOW_MAX_PAGES: usize = 32;
6938const FOLLOW_PAGE: usize = 500;
6939/// Page ceiling for a COMPACTION read (CORD-06 §3: a Refounder that cannot fold
6940/// every Control Event must abort). Far above any real plane, but plane depth is
6941/// attacker-controlled — any member holds the key that mints wraps — so the read
6942/// is bounded and reports coming up short rather than compacting a partial view.
6943const COMPACT_MAX_PAGES: usize = 512;
6944
6945/// A folded control head to persist as the per-entity refuse-downgrade floor.
6946#[derive(Clone)]
6947struct FoldedHead {
6948    entity_hex: String,
6949    version: u64,
6950    self_hash: [u8; 32],
6951    inner_id: [u8; 32],
6952}
6953
6954/// The outcome of a floor-aware control fold: the updated community (if content
6955/// changed), the heads to persist as the new floor (returned even when content is
6956/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
6957/// hit an unresolvable gap — the caller's signal to page older history and re-fold
6958/// (CORD-04 H1/M8's refetch).
6959struct ControlFold {
6960    updated: Option<CommunityV2>,
6961    heads: Vec<FoldedHead>,
6962    gapped: bool,
6963    /// Folded Pin List heads to persist:
6964    /// `(channel_hex, raw content, version, author_npub, created_at)`.
6965    /// Raw carried bytes on purpose — republishing must not re-serialize.
6966    pins_persist: Vec<(String, String, u64, String, u64)>,
6967}
6968
6969/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
6970type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
6971
6972/// Fold owner-authored control editions into an updated community using the
6973/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
6974/// [`version::fold`]`(floor, floor_hash)`:
6975///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
6976///     doesn't block the verified prefix — refuse-downgrade holds for everything
6977///     applied — but flags `gapped` so the caller pages for the rest.
6978///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
6979///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
6980///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
6981///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
6982///     client lands on the same head where a hash-strict floor would wedge forever.
6983///     Anything else is withholding → fail closed + `gapped`.
6984///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
6985///     Refounding, since the caller epoch-filters the floor) takes the highest
6986///     signed head (author already owner-filtered).
6987/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
6988/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
6989/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
6990/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
6991/// convergence preserved).
6992fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
6993    use crate::community::roles::Permissions;
6994    use std::collections::BTreeMap;
6995
6996    let owner_hex = community.owner().ok().map(|o| o.to_hex());
6997
6998    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
6999    for e in editions {
7000        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
7001    }
7002
7003    let mut out = community.clone();
7004    let mut changed = false;
7005    let mut heads = Vec::new();
7006    let mut gapped = false;
7007    let mut pins_persist = Vec::new();
7008    // Pin List eids are one-way HKDF locators, so attribution runs the other
7009    // direction: precompute every known channel's locator. An eid matching no
7010    // channel folds nothing this round — once the channel's metadata lands, the
7011    // next fold attributes it (editions re-fold from the window each sync).
7012    let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
7013        .channels
7014        .iter()
7015        .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
7016        .collect();
7017    for ((vsk_code, eid), group) in &groups {
7018        // This fold applies three entities: community metadata (eid ==
7019        // community_id), channel metadata, and per-channel Pin Lists. A vsk-2
7020        // whose eid equals the community id is excluded — the floor row keys on
7021        // the entity alone, so it would share (and corrupt) the metadata
7022        // chain's floor.
7023        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
7024        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
7025        let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
7026        if !is_meta && !is_channel && pins_channel.is_none() {
7027            continue;
7028        }
7029        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
7030        // entity's management bit are eligible. Pre-filtering before the fold means a
7031        // demoted admin's (possibly higher-version) edition can't be the head; the
7032        // highest AUTHORIZED head wins. The owner is supreme.
7033        let required = if is_meta {
7034            Permissions::MANAGE_METADATA
7035        } else if is_channel {
7036            Permissions::MANAGE_CHANNELS
7037        } else {
7038            Permissions::PIN_MESSAGES
7039        };
7040        let authed: Vec<&ParsedEdition> = group
7041            .iter()
7042            .copied()
7043            .filter(|e| {
7044                let author = e.author.to_hex();
7045                // A banned npub's edits are dropped (CORD-04 §4), even if they still
7046                // held a bit via a not-yet-stripped grant.
7047                !authority.banned.contains(&author)
7048                    && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
7049                    // …and the CORD-04 §5 sync floor. Resolved against the Grant heads
7050                    // this same fold settled, so it works on a bootstrap where no
7051                    // persisted head exists yet.
7052                    && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7053            })
7054            .collect();
7055        if authed.is_empty() {
7056            continue;
7057        }
7058        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7059        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
7060        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7061        gapped |= entity_gapped;
7062        let Some(hi) = hi else { continue };
7063
7064        let head = authed[hi];
7065        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7066        if is_meta {
7067            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
7068                changed |= apply_community_metadata(&mut out, meta);
7069            }
7070        } else if is_channel {
7071            if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
7072                // vsk-2 carries no community binding (shared v1 grammar); a same-owner
7073                // cross-community replay can inject a phantom PUBLIC channel (bounded:
7074                // root-scoped key, eids don't collide). Binding is a deferred wire change.
7075                changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
7076            }
7077        } else if let Some(channel_hex) = pins_channel {
7078            // The RAW carried content, cap-violations included: readers judge
7079            // those (read as empty), and a re-serialization here would break the
7080            // byte cap's meaning and every republish's fidelity.
7081            use nostr_sdk::prelude::ToBech32;
7082            let author_npub = head
7083                .author
7084                .to_bech32()
7085                .unwrap_or_else(|_| head.author.to_hex());
7086            pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
7087        }
7088    }
7089    ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
7090}
7091
7092/// Fold one entity's editions against its persisted floor into a head index (into the
7093/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
7094/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
7095/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
7096/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
7097/// the lower-inner-id winner; and fail closed otherwise.
7098fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
7099    let floor_v = floor.map(|f| f.0).unwrap_or(0);
7100    if floor_v == 0 {
7101        return (version::bootstrap_head(fold_eds, 0), false);
7102    }
7103    let floor_hash = floor.map(|f| &f.1);
7104    let held_inner = floor.and_then(|f| f.2);
7105    let result = version::fold(fold_eds, floor_v, floor_hash);
7106    if result.anchored {
7107        return (result.head, result.gap); // verified prefix; page any upper gap.
7108    }
7109    if result.head.is_none() && !result.gap {
7110        return (None, false); // everything below floor — a stale relay, no paging.
7111    }
7112    // Unanchored under a held floor: converge a same-version fork at the floor to its
7113    // deterministic winner (lower inner id; a NULL held id is always replaceable),
7114    // else fail closed.
7115    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
7116    let win_hash = match fork {
7117        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
7118        _ => return (None, true), // detached from our committed head → withholding.
7119    };
7120    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
7121    if !re.anchored {
7122        return (None, true);
7123    }
7124    (re.head, re.gap)
7125}
7126
7127/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
7128/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
7129/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
7130/// recomputed from the self-certifying community_id at each use.
7131struct AuthoritySet {
7132    roles: crate::community::roles::CommunityRoles,
7133    banned: std::collections::BTreeSet<String>,
7134    heads: Vec<FoldedHead>,
7135    gapped: bool,
7136    /// The authorized banlist `(content, version)` to persist when an authorized head
7137    /// advanced the floor. `None` when the banlist was retained (no new authorized
7138    /// head) or is empty — the caller then leaves the stored banlist untouched.
7139    banlist_persist: Option<(Vec<String>, u64)>,
7140    /// Ban HISTORY: npub hex → `created_at` (secs) of the newest authorized edition that
7141    /// named them, across every edition in the window rather than just the head. Outlives
7142    /// the ban itself so an un-ban can't resurrect a phantom (see [`fold_members`]).
7143    banned_at: std::collections::BTreeMap<String, u64>,
7144}
7145
7146impl AuthoritySet {
7147    /// Bootstrap authority for a community with no roster editions folded yet: only
7148    /// the owner is authorized (supreme), nobody banned.
7149    fn owner_only() -> Self {
7150        AuthoritySet {
7151            roles: Default::default(),
7152            banned: Default::default(),
7153            heads: vec![],
7154            gapped: false,
7155            banlist_persist: None,
7156            banned_at: Default::default(),
7157        }
7158    }
7159}
7160
7161/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
7162/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
7163/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
7164/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
7165/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
7166/// banlist is enforced only if its head's signer held BAN in the authorized roster.
7167fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
7168    use crate::community::roles::Permissions;
7169    use std::collections::BTreeMap;
7170
7171    let cid = community.id();
7172    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
7173    let owner = community.owner().ok();
7174    let owner_hex = owner.map(|o| o.to_hex());
7175    let banlist_eid = super::derive::banlist_locator(cid);
7176    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
7177
7178    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
7179    for e in editions {
7180        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
7181            groups.entry(e.entity_id).or_default().push(e);
7182        }
7183    }
7184
7185    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
7186    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
7187    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
7188    // to the next candidate, so a forged higher-version edition can't suppress the
7189    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
7190    // let any member vanish a role or a member's grant). `gapped` (drives older-
7191    // paging) stays fold_head's per-entity flag.
7192    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7193    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7194    let mut gapped = false;
7195
7196    for (eid, group) in &groups {
7197        // The banlist is folded author-aware AFTER the roster is known (below).
7198        if *eid == banlist_eid {
7199            continue;
7200        }
7201        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7202        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
7203        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7204        gapped |= entity_gapped;
7205        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
7206
7207        for p in group {
7208            // Refuse-downgrade: never consider an edition below the persisted floor.
7209            if p.version < floor_v {
7210                continue;
7211            }
7212            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
7213            match p.vsk.as_str() {
7214                vsk::ROLE => {
7215                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
7216                    if let Some(role) = super::roles::parse_role_content(&p.content) {
7217                        if role.role_id == entity_hex && role.position != 0 {
7218                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
7219                        }
7220                    }
7221                }
7222                vsk::GRANT => {
7223                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
7224                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
7225                            if super::derive::grant_locator(cid, &member) == *eid {
7226                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
7227                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
7228                            }
7229                        }
7230                    }
7231                }
7232                _ => {}
7233            }
7234        }
7235    }
7236    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
7237        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
7238    }
7239
7240    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7241    // Preliminary roster (bans not yet applied) — the authority view the banlist head
7242    // is judged against.
7243    let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty, None);
7244
7245    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
7246    // both closed:
7247    //   - head selection: the head is the highest version whose author CURRENTLY holds
7248    //     BAN — an unauthorized higher-version edition can't erase existing bans
7249    //     (fail-open), and the floor never advances to one;
7250    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
7251    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
7252    //     owner is unbannable);
7253    //   - withholding: when no authorized head is served, the persisted banlist is
7254    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
7255    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
7256    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
7257    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
7258    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
7259    // head eligibility, not just from the roster.
7260    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
7261    let banlist_authored: Vec<&ParsedEdition> = groups
7262        .get(&banlist_eid)
7263        .map(|g| {
7264            g.iter()
7265                .copied()
7266                .filter(|e| {
7267                    let ah = e.author.to_hex();
7268                    !banned_authors.contains(ah.as_str())
7269                        && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
7270                        && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7271                })
7272                .collect()
7273        })
7274        .unwrap_or_default();
7275    // Ban history for phantom suppression: the newest AUTHORIZED edition naming each npub,
7276    // over EVERY candidate rather than only the head — an un-ban replaces the head, so the
7277    // head alone forgets the ban that the suppression exists to remember. The owner is
7278    // skipped: they are never bannable, and a moderator listing them must not durably
7279    // suppress them past the un-ban.
7280    let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
7281    for p in &banlist_authored {
7282        for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
7283            if owner_hex.as_deref() == Some(t.as_str()) {
7284                continue;
7285            }
7286            let slot = banned_at.entry(t).or_insert(0);
7287            *slot = (*slot).max(p.created_at);
7288        }
7289    }
7290    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
7291    let mut banlist_head: Option<FoldedHead> = None;
7292    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
7293        persisted_banned.into_iter().collect()
7294    } else {
7295        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
7296        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
7297        gapped |= g;
7298        match hi {
7299            Some(hi) => {
7300                let head = banlist_authored[hi];
7301                let ah = head.author.to_hex();
7302                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
7303                    .unwrap_or_default()
7304                    .into_iter()
7305                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
7306                    .take(super::roles::MAX_BANLIST)
7307                    .collect();
7308                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7309                banlist_persist = Some((list.clone(), head.version));
7310                list.into_iter().collect()
7311            }
7312            None => persisted_banned.into_iter().collect(),
7313        }
7314    };
7315
7316    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
7317    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
7318    // the banned set excluded so a banned admin loses authority.
7319    UNCITED_ACCEPTED.with(|n| *n.borrow_mut() = 0);
7320    let mut refusals: std::collections::BTreeMap<String, &'static str> = Default::default();
7321    let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned, Some(&mut refusals));
7322    AUTHORITY_REFUSALS.with(|c| *c.borrow_mut() = refusals);
7323    if let Some(bh) = banlist_head {
7324        heads.push(bh);
7325    }
7326
7327    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
7328    // authorization, so an attacker's unauthorized roles can't consume cap slots and
7329    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
7330    // roles empty the roster).
7331    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
7332        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
7333        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
7334        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
7335        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
7336        authorized.grants.retain(|g| !g.role_ids.is_empty());
7337    }
7338
7339    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
7340}
7341
7342/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
7343/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
7344struct AuthorityCand {
7345    role: Option<crate::community::roles::Role>,
7346    grant: Option<crate::community::roles::MemberGrant>,
7347    author: PublicKey,
7348    head: FoldedHead,
7349    /// The `vac` this edition carried (CORD-04 §5). `None` for an owner edition
7350    /// (supreme, cites nothing) or an uncited one — the latter is refused.
7351    citation: Option<crate::community::edition::AuthorityCitation>,
7352}
7353
7354/// CORD-04 §5 sync floor, resolved against the heads THIS fold pass has accepted.
7355///
7356/// Deliberately not the persisted-head helper the kick/hide paths use: this IS the
7357/// pass that establishes those heads, so an external floor would refuse every
7358/// non-owner edition on a bootstrap and the roster could never fold. Same rule the
7359/// spec gives for a dangling `prev` across a Refounding — a fresh joiner takes the
7360/// authority-verified head as its baseline, a tracking client fails closed per
7361/// entity — applied to the citation instead of the chain link.
7362fn citation_ok_in_fold(
7363    cid: &crate::community::CommunityId,
7364    heads: &[FoldedHead],
7365    owner_hex: Option<&str>,
7366    author: &PublicKey,
7367    citation: Option<&crate::community::edition::AuthorityCitation>,
7368) -> bool {
7369    let actor_hex = author.to_hex();
7370    if owner_hex == Some(actor_hex.as_str()) {
7371        return true;
7372    }
7373    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
7374    let as_entity: Vec<crate::community::roster::EntityHead> = heads
7375        .iter()
7376        .map(|h| crate::community::roster::EntityHead {
7377            entity_hex: h.entity_hex.clone(),
7378            version: h.version,
7379            self_hash: h.self_hash,
7380            inner_id: h.inner_id,
7381            citation: None,
7382        })
7383        .collect();
7384    crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
7385}
7386
7387thread_local! {
7388    /// How many uncited editions the last fold admitted on rank alone — pre-citation
7389    /// clients' output. Visibility only; the rank gates decide.
7390    static UNCITED_ACCEPTED: std::cell::RefCell<u32> = const { std::cell::RefCell::new(0) };
7391    /// For a grant refused on a missing role reference: which role ids were absent.
7392    static MISSING_ROLE_REFS: std::cell::RefCell<std::collections::BTreeMap<String, String>> =
7393        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7394    /// Refusal reasons from the most recent authority fold on this thread — read by
7395    /// the roster diagnostic. Diagnostic only; nothing branches on it.
7396    static AUTHORITY_REFUSALS: std::cell::RefCell<std::collections::BTreeMap<String, &'static str>> =
7397        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7398}
7399
7400/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
7401/// takes the highest-version candidate whose author is authorized to author it under
7402/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
7403/// vanishing the entity. Authority resolves outward from the owner (proven by
7404/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
7405/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
7406/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
7407/// advances only to authorized heads — an unauthorized forgery never poisons it).
7408fn select_authorized(
7409    cid: &crate::community::CommunityId,
7410    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7411    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7412    owner_hex: Option<&str>,
7413    excluded: &std::collections::BTreeSet<String>,
7414    // Why each entity was refused, for diagnosis. A rejection here is invisible by
7415    // construction — every gate is a bare `continue` — so a roster that silently
7416    // collapses gives a reader nothing to go on.
7417    mut why: Option<&mut std::collections::BTreeMap<String, &'static str>>,
7418) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
7419    use crate::community::roles::{CommunityRoles, Permissions};
7420    let mut accepted = CommunityRoles::default();
7421    let mut heads: Vec<FoldedHead> = Vec::new();
7422    // Jacobi iteration: authority propagates one delegation level per round, so a
7423    // generous multiple of the entity count is an ample bound. Non-convergence (never
7424    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
7425    // are ever selected.
7426    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
7427    for _ in 0..bound {
7428        let mut next = CommunityRoles::default();
7429        let mut next_heads: Vec<FoldedHead> = Vec::new();
7430
7431        for cands in role_cands.values() {
7432            // Two gates, not one (CORD-04 §2). Minting at a position you outrank
7433            // is necessary but not sufficient: an edition REPLACES the entity, so
7434            // the author must also outrank the position standing before it.
7435            // Without that, an admin at position 5 rewrites the position-1 role
7436            // to position 9 — every check passes, since 9 is beneath them — and
7437            // a role that outranked them is now beneath them, along with everyone
7438            // holding it. Rank inversion by republish.
7439            //
7440            // The chain is replayed ASCENDING so each version is judged against
7441            // the position its own predecessor established, then the highest
7442            // admissible version wins (candidates arrive version-DESC, forks
7443            // broken by lowest inner_id — preserved by walking version groups).
7444            let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
7445            let mut standing: Option<u32> = None;
7446            let mut i = cands.len();
7447            while i > 0 {
7448                let hi = i;
7449                let ver = cands[i - 1].head.version;
7450                while i > 0 && cands[i - 1].head.version == ver {
7451                    i -= 1;
7452                }
7453                // One winner per version: fork siblings can't sidestep the gate.
7454                for c in cands[i..hi].iter().rev() {
7455                    let Some(role) = &c.role else { continue };
7456                    let ah = c.author.to_hex();
7457                    let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7458                        if let Some(m) = w.as_mut() {
7459                            m.insert(c.head.entity_hex.clone(), r);
7460                        }
7461                    };
7462                    if excluded.contains(&ah) || role.position == 0 {
7463                        note(&mut why, "role: author banned or position 0");
7464                        continue;
7465                    }
7466                    if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
7467                        note(&mut why, "role: author lacks MANAGE_ROLES over this position");
7468                        continue;
7469                    }
7470                    if let Some(prev) = standing {
7471                        if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
7472                            note(&mut why, "role: author cannot outrank the position it replaces");
7473                            continue;
7474                        }
7475                    }
7476                    // A `vac` is a SYNC FLOOR, not the verdict (CORD-04 §5): a verifier
7477                    // waits until it has synced the cited Grant, then resolves rank
7478                    // against its CURRENT roster. So a citation we cannot resolve still
7479                    // parks — that is a real sync gap. But an edition carrying NO
7480                    // citation declared no floor to wait for, and refusing it converts a
7481                    // sync aid into an authority requirement the spec says it is not.
7482                    //
7483                    // This cannot escalate: every rank gate above has already passed —
7484                    // the author is authorized under the roster resolved so far and
7485                    // strictly outranks both this position and the one it replaces. The
7486                    // citation never granted authority; it only decided when to look.
7487                    if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7488                        if c.citation.is_some() {
7489                            note(&mut why, "role: cited a grant we cannot resolve");
7490                            continue;
7491                        }
7492                        UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7493                    }
7494                    admissible.insert(c.head.self_hash);
7495                    standing = Some(role.position);
7496                    break;
7497                }
7498            }
7499            for c in cands {
7500                let Some(role) = &c.role else { continue };
7501                if !admissible.contains(&c.head.self_hash) {
7502                    continue;
7503                }
7504                next.roles.push(role.clone());
7505                next_heads.push(c.head.clone());
7506                break; // highest admissible candidate for this entity
7507            }
7508        }
7509        for cands in grant_cands.values() {
7510            for c in cands {
7511                let c2 = c;
7512                let Some(grant) = &c.grant else { continue };
7513                let ah = c.author.to_hex();
7514                let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7515                    if let Some(m) = w.as_mut() {
7516                        m.insert(c.head.entity_hex.clone(), r);
7517                    }
7518                };
7519                if excluded.contains(&ah) || excluded.contains(&grant.member) {
7520                    note(&mut why, "grant: author or member banned");
7521                    continue;
7522                }
7523                // The granter must outrank every granted role (resolved against the
7524                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
7525                //
7526                // Resolve the roles that CAN be resolved rather than discarding the
7527                // grant whole. All-or-nothing deadlocks a community that grew the
7528                // ordinary way: an admin creates a role, the owner grants that role to
7529                // them, and now the grant needs the role accepted while the role needs
7530                // the grant. Neither can go first, the fixpoint lands empty, and the
7531                // entire roster — the owner's own grants included — collapses. Seen on
7532                // two live communities; the cached roster was all that hid it.
7533                //
7534                // Partial resolution is strictly fail-closed: it can only ever grant
7535                // FEWER roles than the edition names, every retained role is still
7536                // rank-checked below, and the unresolved ones fold in on a later
7537                // iteration once their own authority settles. Nothing is granted that
7538                // the author did not write.
7539                let resolved: Vec<(String, u32)> = grant
7540                    .role_ids
7541                    .iter()
7542                    .filter_map(|rid| accepted.role(rid).map(|r| (rid.clone(), r.position)))
7543                    .collect();
7544                let positions: Vec<u32> = resolved.iter().map(|(_, p)| *p).collect();
7545                if resolved.is_empty() && !grant.role_ids.is_empty() {
7546                    // Record WHICH role is missing — a grant refused for pointing at
7547                    // an unaccepted role is the head of a dependency chain, and the
7548                    // chain is unreadable without naming its next link.
7549                    if let Some(m) = why.as_mut() {
7550                        let missing: Vec<String> = grant
7551                            .role_ids
7552                            .iter()
7553                            .filter(|rid| accepted.role(rid).is_none())
7554                            .map(|rid| rid[..8.min(rid.len())].to_string())
7555                            .collect();
7556                        MISSING_ROLE_REFS.with(|c| {
7557                            c.borrow_mut().insert(c2.head.entity_hex.clone(), missing.join("+"));
7558                        });
7559                        m.insert(c2.head.entity_hex.clone(), "grant: references a role not in the accepted roster");
7560                    }
7561                    continue;
7562                }
7563                // An unresolvable citation parks; an absent one is tolerated ONLY when
7564                // the edition grants something. The rank gate below is what stops
7565                // escalation, and it needs a position to check: a REVOCATION carries no
7566                // role ids, so `positions.iter().all(..)` is vacuously true and the
7567                // citation is the only thing standing there. Without this an
7568                // unauthorized stranger strips any member's roles with a forged empty
7569                // grant — proven by `a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version`.
7570                //
7571                // So: an uncited edition may ADD authority (fully rank-checked) but
7572                // never REMOVE it.
7573                if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7574                    if c.citation.is_some() {
7575                        note(&mut why, "grant: cited a grant we cannot resolve");
7576                        continue;
7577                    }
7578                    if resolved.is_empty() {
7579                        note(&mut why, "grant: UNCITED revocation — a strip must cite its authority");
7580                        continue;
7581                    }
7582                    UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7583                }
7584                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
7585                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
7586                {
7587                    // Record the head even for an EMPTY grant (a revoke is a real chain
7588                    // advance a completeness check must see), but don't carry the husk
7589                    // into the roster.
7590                    next_heads.push(c.head.clone());
7591                    if !resolved.is_empty() {
7592                        // Carry only the resolved subset; the rest arrive on a later
7593                        // iteration, or never, if they were never legitimate.
7594                        let mut g = grant.clone();
7595                        g.role_ids = resolved.iter().map(|(rid, _)| rid.clone()).collect();
7596                        next.grants.push(g);
7597                    }
7598                    break;
7599                }
7600            }
7601        }
7602
7603        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
7604        accepted = next;
7605        heads = next_heads;
7606        if converged {
7607            break;
7608        }
7609    }
7610    (accepted, heads)
7611}
7612
7613/// Apply a folded community-metadata head. Relays only overwrite when the edition
7614/// carries a non-empty list (a metadata edition that omits relays must not blank
7615/// the working set). Returns whether anything changed.
7616fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
7617    let mut changed = false;
7618    if out.name != meta.name {
7619        out.name = meta.name;
7620        changed = true;
7621    }
7622    if out.description != meta.description {
7623        out.description = meta.description;
7624        changed = true;
7625    }
7626    // Icon/banner apply verbatim, None included — an edition is the full
7627    // document, so an absent image IS a removal (editors preserve via
7628    // `CommunityV2::metadata()`).
7629    if out.icon != meta.icon {
7630        out.icon = meta.icon;
7631        changed = true;
7632    }
7633    if out.banner != meta.banner {
7634        out.banner = meta.banner;
7635        changed = true;
7636    }
7637    // Client-extensible + unknown fields ride the fold verbatim so our own
7638    // editions can carry them forward (CORD-02 §6).
7639    if out.meta_custom != meta.custom {
7640        out.meta_custom = meta.custom;
7641        changed = true;
7642    }
7643    if out.meta_extra != meta.extra {
7644        out.meta_extra = meta.extra;
7645        changed = true;
7646    }
7647    // CAP on the way in. `cap_relays` is the truncate-on-read invariant for every
7648    // other construction boundary, and the fold is a boundary like any other: an
7649    // authorized editor is not a trusted one, and an oversize list costs every
7650    // member a fan-out on each publish and the slowest of N on each fetch
7651    // (CORD-02 §6 makes trimming explicitly a client's call). Compare against the
7652    // CAPPED list too — against the raw one, an oversize edition never compares
7653    // equal, so every fold would report a change and re-save forever.
7654    let relays = crate::community::cap_relays(meta.relays);
7655    if !relays.is_empty() && out.relays != relays {
7656        out.relays = relays;
7657        changed = true;
7658    }
7659    changed
7660}
7661
7662/// Apply a folded channel-metadata head: delete removes the channel, a rename
7663/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
7664/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
7665/// rekey plane or a fresh bundle). Returns whether anything changed.
7666fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
7667    let deleted = meta.deleted.unwrap_or(false);
7668    if deleted {
7669        let before = out.channels.len();
7670        out.channels.retain(|c| c.id.0 != id.0);
7671        return out.channels.len() != before;
7672    }
7673    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
7674        Some(existing) => {
7675            let mut changed = false;
7676            if existing.name != meta.name {
7677                existing.name = meta.name;
7678                changed = true;
7679            }
7680            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
7681            // local edit republishes them instead of wiping (CORD-02 §6).
7682            if existing.voice != meta.voice {
7683                existing.voice = meta.voice;
7684                changed = true;
7685            }
7686            if existing.meta_custom != meta.custom {
7687                existing.meta_custom = meta.custom;
7688                changed = true;
7689            }
7690            if existing.meta_extra != meta.extra {
7691                existing.meta_extra = meta.extra;
7692                changed = true;
7693            }
7694            // The owner's edition authoritatively declares visibility. A channel the
7695            // owner marks PUBLIC must derive from the root (key = None) — this heals a
7696            // bundle-time misclassification where an attacker set a public channel's
7697            // grant key to their own, silently addressing it at a plane only they read.
7698            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
7699            // record stays public) until the convert flow (key mint + cursor rebase
7700            // to the conversion's channel epoch) lands — the send side refuses to
7701            // publish one, and a foreign client's conversion won't move us.
7702            if !meta.private && (existing.private || existing.key.is_some()) {
7703                existing.private = false;
7704                existing.key = None;
7705                changed = true;
7706            }
7707            changed
7708        }
7709        None if !meta.private => {
7710            // A public channel derives its Chat Plane from the community_root at the
7711            // current root epoch (key = None); its stored epoch mirrors the root.
7712            out.channels.push(ChannelV2 {
7713                id,
7714                name: meta.name,
7715                private: false,
7716                key: None,
7717                epoch: out.root_epoch,
7718                voice: meta.voice,
7719                meta_custom: meta.custom,
7720                meta_extra: meta.extra,
7721            });
7722            true
7723        }
7724        None => {
7725            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
7726            // generation — CORD-03 §2 numbers the first private key epoch 1). The
7727            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
7728            // delivers a key, every read/send/subscribe path skips the channel; the
7729            // root-fallback in `channel_secret` is never taken for it.
7730            out.channels.push(ChannelV2 {
7731                id,
7732                name: meta.name,
7733                private: true,
7734                key: None,
7735                epoch: Epoch(0),
7736                voice: meta.voice,
7737                meta_custom: meta.custom,
7738                meta_extra: meta.extra,
7739            });
7740            true
7741        }
7742    }
7743}
7744
7745// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
7746
7747/// The outcome of a rekey-follow pass.
7748pub struct RekeyFollow {
7749    /// The community after adopting every rotation it could catch up on, or `None`
7750    /// if nothing advanced.
7751    pub updated: Option<CommunityV2>,
7752    /// A base rotation removed us — the caller tears the local hold down (the
7753    /// updated community is not persisted in that case).
7754    pub self_removed: bool,
7755    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
7756    /// flag is already set; the caller surfaces the death and stops following.
7757    pub dissolved: bool,
7758    /// A complete authorized rotation carried a blob AT OUR LOCATOR that would
7759    /// not open (a width this build predates, a rotator bug, or a deliberately
7760    /// undecryptable blob planted to pass an admissibility check while severing
7761    /// us). Never a removal (CORD-06 §2: removal is the ABSENCE of a blob), so
7762    /// the walk parks — but the park is otherwise indistinguishable from "no
7763    /// rotation happened", which is exactly what makes it abusable. Surfaced so
7764    /// the user learns their key delivery is broken while the prior root still
7765    /// reaches everyone.
7766    pub wedged: bool,
7767}
7768
7769/// The most archived base roots a channel-rekey lookup fans across per step. A
7770/// standalone rekey rides the minter's then-current root and a removal's rides the
7771/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
7772/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
7773/// an older root than the fan reaches) only heals via a fresh invite bundle — the
7774/// walk is strictly sequential, so a later rotation can't be reached either.
7775const MAX_ADDRESSING_ROOTS: usize = 8;
7776
7777/// The base roots a channel rekey may be addressed under, freshest first: the
7778/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
7779/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
7780/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
7781/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
7782/// fetch addresses but auth never registered is invisible on an AUTH-gating
7783/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
7784/// wedges at its old epoch while the base advances.
7785pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
7786    let mut roots: Vec<[u8; 32]> = vec![cur_root];
7787    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
7788        .unwrap_or_default();
7789    archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
7790    for (_, r) in archived {
7791        if !roots.contains(&r) {
7792            roots.push(r);
7793        }
7794    }
7795    roots.truncate(MAX_ADDRESSING_ROOTS);
7796    roots
7797}
7798
7799/// Follow rekeys for a held community: advance the base (root) epoch and each
7800/// Private channel's epoch as far as authorized rotations allow, adopting the
7801/// fresh key we're still a recipient of at each step and dropping a scope we've
7802/// been removed from. Persists the result. Called when a rekey wrap arrives in
7803/// realtime so a long-running bot keeps decrypting after a rotation instead of
7804/// going silent.
7805///
7806/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
7807/// or any `BAN` holder, and a CHANNEL rotation from the owner or a
7808/// `MANAGE_CHANNELS` holder — both under the PERSISTED roster (folded by
7809/// `follow_control`), minus the banlist, so an admin-created private channel
7810/// keys up on every member. Concluding OUR OWN removal takes more than the bit:
7811/// the rotator must strictly outrank us (`*_outranks_me`), so an equal-rank
7812/// admin can never evict a peer by minting a rotation that skips their blob.
7813///
7814/// **Addressing fans across held base roots:** each channel step queries its
7815/// next-epoch rekey address under the current root AND the archived prior roots,
7816/// so a base adopt landing before a Refounding's prior-root-addressed channel
7817/// rekeys (or before a creation delivery minted under an older root) can't
7818/// strand the channel.
7819///
7820/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
7821/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
7822/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
7823/// every follower converges. An incomplete rotation (a missing chunk) never
7824/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
7825/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
7826/// §2: "a convergence check, not a secrecy mechanism") — authority is its
7827/// boundary; its epoch is the scan cursor, advancing past complete rotations
7828/// that exclude us so the walk converges on the channel's current epoch.
7829/// Explain this client's epoch state: what we hold, what the next-epoch base plane
7830/// offers, and — per rotation found there — exactly which `advance_scope` gate
7831/// decided we did or did not adopt it.
7832///
7833/// **Ships in release builds deliberately.** A strand is silent by construction: a
7834/// client parked on a dead epoch is subscribed at its own addresses and simply sees
7835/// nothing, which is indistinguishable from a quiet community. Answering "why is
7836/// this client stuck" without this meant reading sqlite by hand. It is also the
7837/// instrument for comparing two clients' views of one community — the local half
7838/// (`held`) is what diverges first.
7839///
7840/// Read-only, and safe to surface: every rotator/owner is a PUBLIC key, and no key
7841/// material, blob plaintext or roster secret is returned.
7842pub async fn explain_epoch_state<T: Transport + ?Sized>(
7843    transport: &T,
7844    community: &CommunityV2,
7845) -> Result<serde_json::Value, String> {
7846    let my_xonly = me_pk()?.to_bytes();
7847    let owner = community.owner()?;
7848    let owner_hex = owner.to_hex();
7849    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7850    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
7851    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
7852    let held_epoch = community.root_epoch;
7853    let held_key = community.community_root;
7854    let next = Epoch(held_epoch.0.saturating_add(1));
7855    let group = base_rekey_group_key(&held_key, community.id(), next);
7856    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
7857    let rotations = rekey::collect_rotations(&chunks);
7858
7859    let reports: Vec<serde_json::Value> = rotations
7860        .iter()
7861        .map(|r| {
7862            let rotator_is_owner = r.rotator == owner;
7863            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
7864            // Roster, not owner-identity — report that gate, not just owner-equality.
7865            let rotator_authorized = rotator_is_owner
7866                || (!banned.contains(&r.rotator.to_hex())
7867                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
7868            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
7869            let epoch_ok = r.new_epoch.0 == next.0;
7870            let complete = r.is_complete();
7871            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
7872            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
7873            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
7874            // is a takeover attempt — this tells whether an "owner must be kept"
7875            // adopt-block would be safe here (it would falsely reject a legitimate
7876            // rotation that happened to exclude the owner).
7877            let owner_kept = r.rotator == owner
7878                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
7879            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
7880            let verdict = if !rotator_authorized {
7881                "REJECTED: rotator holds no BAN authority in the folded roster"
7882            } else if !scope_ok {
7883                "REJECTED: scope is not Root"
7884            } else if !epoch_ok {
7885                "REJECTED: new_epoch != held+1"
7886            } else if !complete {
7887                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
7888            } else if continuity != "Extends" {
7889                "REJECTED: continuity does not extend my held root (FORK/GAP)"
7890            } else if has_my_blob {
7891                "ADOPT: authorized + complete + continuous + my blob present"
7892            } else {
7893                "REMOVED: complete authorized rotation with no blob for me"
7894            };
7895            serde_json::json!({
7896                "rotator": r.rotator.to_hex(),
7897                "rotator_is_recorded_owner": rotator_is_owner,
7898                "rotator_authorized_ban": rotator_authorized,
7899                "scope_is_root": scope_ok,
7900                "new_epoch": r.new_epoch.0,
7901                "prev_epoch": r.prev_epoch.0,
7902                "declared_chunks": r.declared_chunks,
7903                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
7904                "is_complete": complete,
7905                "continuity_vs_held_root": continuity,
7906                "my_blob_present": has_my_blob,
7907                "owner_kept": owner_kept,
7908                "blob_count": r.blobs.len(),
7909                "severing": r.severed,
7910                "verdict": verdict,
7911            })
7912        })
7913        .collect();
7914
7915    // The LOCAL half. Two clients on one community diverge here first, and every
7916    // field is a thing that has silently frozen a client in the field: the roster
7917    // is what authorizes a rotator, its provenance is what the freeze-guard reads,
7918    // and the invite registry is what Public/Private hangs off.
7919    let roster_at = crate::db::community::get_community_roles_at(&cid_hex).unwrap_or(0);
7920    let admin_count = roster.grants.iter().filter(|g| roster.is_admin(&g.member)).count();
7921    let held = serde_json::json!({
7922        "root_epoch": held_epoch.0,
7923        "roster_roles": roster.roles.len(),
7924        "roster_grants": roster.grants.len(),
7925        "roster_admins": admin_count,
7926        // 0 = a locally-seeded roster that never folded from the plane. A client
7927        // whose roster never gains provenance can never authorize a non-owner
7928        // admin, so it refuses their rotations forever.
7929        "roster_provenance_at": roster_at,
7930        "banlist_entries": banned.len(),
7931        "sever_marks": sever_marks(&cid_hex).keys().copied().collect::<Vec<_>>(),
7932        "control_plane_split": community.control_pk.is_some(),
7933        "private_channels": community.channels.iter().filter(|c| c.private).count(),
7934        "channels_awaiting_key": community.channels.iter().filter(|c| c.private && c.key.is_none()).count(),
7935    });
7936
7937    // The one-line answer. `probing_next_epoch` existing at all proves the community
7938    // moved without us; what we did about it is the `rotations` verdicts.
7939    let summary = if rotations.is_empty() {
7940        "UP TO DATE (or the plane is unreachable): no rotation offered at held+1"
7941    } else if reports.iter().any(|r| r["verdict"].as_str().is_some_and(|v| v.starts_with("ADOPT"))) {
7942        "BEHIND: an adoptable rotation is waiting — run a sync"
7943    } else {
7944        "WEDGED: a rotation exists at held+1 and every one was refused (see verdicts)"
7945    };
7946
7947    Ok(serde_json::json!({
7948        "summary": summary,
7949        "recorded_owner": owner.to_hex(),
7950        "held": held,
7951        "held_root_epoch": held_epoch.0,
7952        "probing_next_epoch": next.0,
7953        "base_plane_pk": group.pk_hex(),
7954        "raw_chunks_parsed": chunks.len(),
7955        "rotations_found": rotations.len(),
7956        "rotations": reports,
7957    }))
7958}
7959
7960pub async fn follow_rekeys<T: Transport + ?Sized>(
7961    transport: &T,
7962    community: &CommunityV2,
7963    session: &std::sync::Arc<crate::db::Session>,
7964) -> Result<RekeyFollow, String> {
7965    crate::db::scoped(async move {
7966        // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
7967        // past its tombstone — don't adopt a rotation into a grave.
7968        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7969        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
7970            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
7971        }
7972        // An offline member must also LEARN of a death: the tombstone rides its own
7973        // public plane, which the live sub watches but no catch-up fetch touched —
7974        // without this, a member who slept through a dissolution follows (and posts
7975        // into) a grave forever. Fail-open on transport failure: availability is
7976        // never death.
7977        if is_dissolved(transport, community).await {
7978            if session.is_live() {
7979                let _ = crate::db::community::set_community_dissolved(&cid_hex);
7980            }
7981            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
7982        }
7983        let signer = crate::signer::active_signer()?;
7984        let my_pk = me_pk()?;
7985        let my_xonly = my_pk.to_bytes();
7986        let owner = community.owner()?;
7987        let owner_hex = owner.to_hex();
7988        let mut cur = community.clone();
7989        let mut changed = false;
7990        // A rotation delivered us a blob we could not open (see RekeyFollow::wedged).
7991        let mut wedged = false;
7992
7993        // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
7994        // follow_control; the worker folds control right after this rekey pass). This
7995        // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
7996        // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
7997        // the base-admissibility protected-set: a superior whose grant this receiver
7998        // has not yet folded is not in `roster.grants`, so a non-owner Refounding
7999        // excluding them can be adopted within that propagation window. Bounded — the
8000        // owner is ALWAYS hard-protected below (independent of the roster) and can
8001        // counter-refound; and it is inherent to eventual consistency (one cannot gate
8002        // on a grant never seen). Tightening this (fold control before the first rekey,
8003        // or gate non-owner adoption on roster freshness) is a follow-on.
8004        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
8005        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
8006        let me_hex = my_pk.to_hex();
8007        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
8008        // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
8009        // never honored by a lagging client." Persisted heads ARE the right floor
8010        // here (unlike the roster fold, which must resolve in-pass): a rotation is
8011        // judged against a roster we already folded, and `follow_control` — v2's only
8012        // roster writer — persists the heads in the same pass it writes the roster.
8013        // A joiner who sees a rotation before folding control simply parks it and
8014        // heals on the next follow, which runs control first.
8015        let cited_ok = |rot: &rekey::Rotation| -> bool {
8016            citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
8017        };
8018        let channel_rotator_ok = |rotator: &PublicKey| -> bool {
8019            if *rotator == owner {
8020                return true;
8021            }
8022            let rh = rotator.to_hex();
8023            !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
8024        };
8025        // Concluding MY removal takes more than the bit: the rotator must strictly
8026        // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
8027        // removed target"), so an equal-rank admin can never silently evict a peer
8028        // (or the owner) by minting a complete rotation that skips their blob.
8029        let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8030            if *rotator == owner {
8031                return true;
8032            }
8033            let rh = rotator.to_hex();
8034            !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
8035        };
8036        // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
8037        // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
8038        // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
8039        // owner is always authorized. (Owner-only here silently wedged every member
8040        // whose community was refounded by a non-owner admin.)
8041        let base_rotator_ok = |rotator: &PublicKey| -> bool {
8042            if *rotator == owner {
8043                return true;
8044            }
8045            let rh = rotator.to_hex();
8046            !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
8047        };
8048        // Concluding MY removal via a base rotation takes more than the bit: the
8049        // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
8050        // Rotator must strictly outrank every removed target"), so an equal-rank
8051        // admin can never evict a peer (or the owner) by minting a rotation that
8052        // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
8053        let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8054            if *rotator == owner {
8055                return true;
8056            }
8057            let rh = rotator.to_hex();
8058            !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
8059        };
8060
8061                // A non-owner Refounding may only remove members the rotator strictly
8062        // OUTRANKS. The protected set is the owner plus every grant-holder the
8063        // rotator can't act on with BAN (a peer or superior) — excluding one is
8064        // an authority-escalation takeover, so its rotation is inadmissible.
8065        // Plain members hold no grant and are always outranked by a BAN-holder,
8066        // so removing them is legitimate and needs no memberlist.
8067        let base_admissible = |r: &rekey::Rotation| -> bool {
8068            if r.rotator == owner {
8069                return true; // the owner is supreme.
8070            }
8071            // Uncited (or citing a Grant we haven't synced) → skip entirely:
8072            // neither adopt nor conclude a removal, exactly like an
8073            // unauthorized rotation. It parks and heals on the next follow.
8074            if !cited_ok(r) {
8075                return false;
8076            }
8077            let rotator_hex = r.rotator.to_hex();
8078            let has_blob = |xonly: &[u8; 32]| {
8079                rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
8080            };
8081            // The owner is never a valid removed target.
8082            if !has_blob(&owner.to_bytes()) {
8083                return false;
8084            }
8085            for g in &roster.grants {
8086                if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
8087                    continue; // self, owner (checked), or an already-authorized removal.
8088                }
8089                // A grant-holder the rotator can't act on is a peer/superior.
8090                if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
8091                    if let Ok(pk) = PublicKey::from_hex(&g.member) {
8092                        if !has_blob(&pk.to_bytes()) {
8093                            return false; // a peer/superior was excluded.
8094                        }
8095                    }
8096                }
8097            }
8098            true
8099        };
8100
8101        // ── Same-epoch convergence, BEFORE walking forward (CORD-06 §3) ─────────
8102        //
8103        // Two authorized rotators can mint the same epoch concurrently. The spec's
8104        // tiebreak is deterministic — the lexicographically lowest new base key wins
8105        // — but it was only ever applied among rotations seen in ONE fetch. A client
8106        // that saw only the higher sibling adopted it and then looked exclusively
8107        // for its successor, so the lower sibling became permanently unreachable:
8108        // two halves of a community, both at the same epoch number, different keys,
8109        // unable to read each other, forever. Nobody was misbehaving.
8110        //
8111        // So re-check the epoch we ALREADY hold on every follow, and step sideways
8112        // onto a strictly lower sibling. Down-only, exactly as the spec requires: a
8113        // held epoch converges only downward, so a flaky fetch that returns just the
8114        // higher sibling can never re-fork a settled epoch, and the walk terminates
8115        // because each convergence strictly decreases the held key.
8116        //
8117        // The losing branch's key is simply dropped. Messages already ingested from
8118        // it stay readable — they are stored decrypted under the account vault, not
8119        // re-derived from the epoch key — so converging costs only the fork-window
8120        // messages this client never fetched, which live on a branch nobody reads.
8121        if cur.root_epoch.0 > 0 {
8122            let prior = crate::db::community::held_epoch_key(
8123                &cid_hex,
8124                crate::community::SERVER_ROOT_SCOPE_HEX,
8125                cur.root_epoch.0 - 1,
8126            )
8127            .ok()
8128            .flatten();
8129            if let Some(prior_root) = prior {
8130                let group = base_rekey_group_key(&prior_root, cur.id(), cur.root_epoch);
8131                match fetch_rekey_chunks(transport, &cur.relays, &group).await {
8132                    Ok(siblings) if !siblings.is_empty() => {
8133                        let held_before = cur.community_root;
8134                        match advance_scope(
8135                            &[(siblings, Some((Epoch(cur.root_epoch.0 - 1), prior_root)))],
8136                            RekeyScope::Root,
8137                            cur.id(),
8138                            &base_rotator_ok,
8139                            &base_rotator_outranks_me,
8140                            &base_admissible,
8141                            &signer,
8142                            &my_xonly,
8143                            cur.root_epoch,
8144                        )
8145                        .await
8146                        {
8147                            // Strictly lower only. Equal = the branch we already hold.
8148                            Advance::Adopt { new_key, control_pk, control_root, .. } if new_key < held_before => {
8149                                crate::log_warn!(
8150                                    "[v2:follow {}] same-epoch fork at epoch {} — converging DOWN onto the canonical sibling",
8151                                    &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8152                                );
8153                                cur.community_root = new_key;
8154                                cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8155                                cur.control_root = control_root;
8156                                if let Err(e) = crate::db::community::store_epoch_key(
8157                                    &cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, cur.root_epoch.0, &new_key,
8158                                ) {
8159                                    crate::log_warn!("v2: converged epoch-key archive failed: {e}");
8160                                }
8161                                if matches!(crate::db::community::community_protocol(community.id()), Ok(Some(_))) {
8162                                    let _ = crate::db::community::save_community_v2(&cur);
8163                                }
8164                                changed = true;
8165                            }
8166                            // A sibling we cannot open is NOT a removal: refusing to
8167                            // converge parks us on a branch that still works, where
8168                            // concluding removal here would delete the community over
8169                            // a fork we merely could not read.
8170                            _ => {}
8171                        }
8172                    }
8173                    Ok(_) => {}
8174                    Err(e) => crate::log_warn!(
8175                        "[v2:follow {}] same-epoch fork check failed (will retry): {}",
8176                        &cid_hex[..8.min(cid_hex.len())], e
8177                    ),
8178                }
8179            }
8180        }
8181
8182        // Bound the catch-up: each real step consumes a valid authorized rotation, so a
8183        // finite chain terminates naturally; the cap defends against a relay feeding a
8184        // pathological set.
8185        const MAX_STEPS: usize = MAX_WALK_STEPS;
8186        // Wide enough to collapse a large community's channel fan into one wave,
8187        // narrow enough that a catch-up does not present as a burst to every relay.
8188        const CHANNEL_FAN_CONCURRENCY: usize = 8;
8189        // Containment hops adopted in this walk (B2: any one of them condemns —
8190        // a later ordinary hop in the same walk must not relaunder links open).
8191        let mut severed_hops: Vec<u64> = Vec::new();
8192        for _ in 0..MAX_STEPS {
8193            let mut advanced = false;
8194
8195            // The roots a channel rekey may be addressed under (re-read each pass —
8196            // a base adopt below changes the head, and its predecessor is already
8197            // archived). Shared with streamauth so the auth registration covers
8198            // exactly this fan.
8199            let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
8200
8201            // Private channels first: a removal-forced channel rekey rides the PRIOR
8202            // root (CORD-06 D2), so read channels before a base adopt moves it.
8203            let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
8204            // One concurrent wave per hop, not one round-trip per (channel, root).
8205            // Serially this was `private channels x addressing roots` fetches before a
8206            // single hop could be decided — around a hundred for a large community, and
8207            // a week's absence walks dozens of hops. The decisions below still run
8208            // serially and in the same order; only the reads overlap.
8209            let fan: Vec<(ChannelId, Option<[u8; 32]>, Epoch, Epoch)> = channel_ids
8210                .iter()
8211                .filter_map(|cid| {
8212                    cur.channel(cid).map(|ch| {
8213                        (*cid, ch.key, ch.epoch, Epoch(ch.epoch.0.saturating_add(1)))
8214                    })
8215                })
8216                .collect();
8217            let mut prefetched: std::collections::HashMap<([u8; 32], usize), Vec<rekey::RekeyChunk>> =
8218                std::collections::HashMap::new();
8219            {
8220                use futures_util::stream::StreamExt;
8221                // root #0 = current, #1.. = archived priors (indices only — root
8222                // bytes are key material and must never reach a log).
8223                let jobs: Vec<(ChannelId, usize, GroupKey, Epoch)> = fan
8224                    .iter()
8225                    .flat_map(|(cid, _, _, next)| {
8226                        addressing_roots.iter().enumerate().map(move |(ri, root)| {
8227                            (*cid, ri, channel_rekey_group_key(root, cid, *next), *next)
8228                        })
8229                    })
8230                    .collect();
8231                let relays = &cur.relays;
8232                let mut results = futures_util::stream::iter(jobs)
8233                    .map(|(cid, ri, group, next)| async move {
8234                        (cid, ri, next, fetch_rekey_chunks(transport, relays, &group).await)
8235                    })
8236                    .buffer_unordered(CHANNEL_FAN_CONCURRENCY);
8237                while let Some((cid, ri, next, out)) = results.next().await {
8238                    let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8239                    match out {
8240                        Ok(chunks) => {
8241                            if chunks.is_empty() {
8242                                continue;
8243                            }
8244                            crate::log_debug!(
8245                                "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
8246                                &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
8247                            );
8248                            prefetched.insert((cid.0, ri), chunks);
8249                        }
8250                        Err(e) => {
8251                            crate::log_warn!(
8252                                "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
8253                                &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
8254                            );
8255                            return Err(e);
8256                        }
8257                    }
8258                }
8259            }
8260
8261            for (cid, held_key, held_epoch, next) in fan {
8262                let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8263                let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
8264                for ri in 0..addressing_roots.len() {
8265                    if let Some(chunks) = prefetched.remove(&(cid.0, ri)) {
8266                        batches.push((chunks, held_key.map(|k| (held_epoch, k))));
8267                    }
8268                }
8269                // Keyless-adopt residual (documented, deferred hardening): a malicious
8270                // AUTHORIZED admin can fork a keyless member onto an orphan low-key
8271                // rotation nothing extends (keyed members' continuity filters it out).
8272                // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
8273                // exclude the member outright anyway, so the marginal harm is the wedge
8274                // outliving their demotion.
8275                match advance_scope(&batches, RekeyScope::Channel(cid), cur.id(), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
8276                    Advance::Adopt { new_key, .. } => {
8277                        if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8278                            ch.key = Some(new_key);
8279                            ch.epoch = next;
8280                        }
8281                        crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
8282                        // The adopter's own multi-epoch archive (the minter archived at
8283                        // mint) — this channel's history stays readable across rotations.
8284                        // fetch_channel compensates for the CURRENT epoch, so a failed
8285                        // archive only bites after the NEXT rotation — surface it.
8286                        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) {
8287                            crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
8288                        }
8289                        advanced = true;
8290                        changed = true;
8291                    }
8292                    Advance::Removed => {
8293                        match held_key {
8294                            // A complete rotation dropped my blob — cut from the channel.
8295                            Some(_) => {
8296                                cur.channels.retain(|c| c.id.0 != cid.0);
8297                            }
8298                            // Keyless scan: this epoch's rotation completed without me.
8299                            // Advance the cursor so the walk converges on the channel's
8300                            // CURRENT epoch — my entry point is its next rotation (whose
8301                            // recipients are the members at that time) or a fresh bundle.
8302                            None => {
8303                                if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8304                                    ch.epoch = next;
8305                                }
8306                            }
8307                        }
8308                        advanced = true;
8309                        changed = true;
8310                    }
8311                    Advance::Stay => {}
8312                    Advance::StayWedged => {
8313                        // A blob at our locator that will not open: park, never
8314                        // remove, and make the park legible (a silent park is
8315                        // indistinguishable from "no rotation happened").
8316                        wedged = true;
8317                    }
8318                }
8319            }
8320
8321            // Base rotation (Refounding): advances the root + root_epoch, re-addressing
8322            // every public channel, the guestbook, and the control plane by derivation
8323            // (refresh_subscription recomputes the author-set from the new root).
8324            {
8325                let held_epoch = cur.root_epoch;
8326                let held_key = cur.community_root;
8327                let next = Epoch(held_epoch.0.saturating_add(1));
8328                let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
8329                let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
8330                let batches = vec![(chunks, Some((held_epoch, held_key)))];
8331                match advance_scope(&batches, RekeyScope::Root, cur.id(), &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
8332                    Advance::Adopt { new_key, control_pk, control_root, severed, rotator } => {
8333                        // CORD-02 §5: this npub's snapshot — and only this npub's —
8334                        // seeds the new epoch's Guestbook. Recorded from the rotation
8335                        // this device just VERIFIED and adopted, so a snapshot can
8336                        // never nominate its own author.
8337                        record_epoch_minter(&cid_hex, next.0, &rotator.to_hex());
8338                        // Record the HOP that was severed, not the walk's final head:
8339                        // the mark drives both the link condemnation line and the
8340                        // rescue re-stamp, and a multi-step walk would otherwise
8341                        // present the containment at the wrong epoch.
8342                        if severed {
8343                            severed_hops.push(next.0);
8344                        }
8345                        cur.community_root = new_key;
8346                        cur.root_epoch = next;
8347                        // The control pair is the blob's, never inherited: the
8348                        // secret rolls with the root at every Refounding
8349                        // (CORD-02 §2), so a stale pair carried forward would sign
8350                        // (or subscribe) at a dead address.
8351                        //
8352                        // A legacy 72-byte blob therefore DOWNGRADES a split
8353                        // community back to the member-writable plane — the exact
8354                        // flooding surface the split closes. The width stays
8355                        // accepted (transition compat, CORD-06 §3), but once a
8356                        // community has been split this is either an outdated
8357                        // rotator or an attack, so it is never silent.
8358                        if cur.control_pk.is_some() && control_pk.is_none() {
8359                            crate::log_warn!(
8360                                "[v2:follow {}] rotation to epoch {} carried a LEGACY base blob — this community's control plane reverts to the member-writable address",
8361                                &cid_hex[..8.min(cid_hex.len())], next.0
8362                            );
8363                        }
8364                        cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8365                        cur.control_root = control_root;
8366                        // Archive on adopt: without this, a member who lived through TWO
8367                        // Refoundings loses the middle epoch's public history (only the
8368                        // minter archived it).
8369                        if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
8370                            crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
8371                        }
8372                        // CHECKPOINT THE HOP. A client back from a long absence walks
8373                        // many epochs in one pass, and the row used to be written only
8374                        // after the whole loop — so a fetch failure on the last hop
8375                        // discarded every hop already taken. The next attempt started
8376                        // from scratch, cost the same again, and on a flaky link could
8377                        // never converge at all. Banking here makes the walk resumable:
8378                        // progress is monotonic, and an interrupted catch-up picks up
8379                        // where it stopped.
8380                        //
8381                        // The FULL row, never just root+epoch: `control_pk`/
8382                        // `control_root` roll with the root, so a narrow write would
8383                        // leave the control pair an epoch behind and fold the control
8384                        // plane at a dead address. Archive first, then the head — a
8385                        // stray archive is harmless, a missing one is permanent.
8386                        //
8387                        // A leave/delete racing the walk must not be resurrected by an
8388                        // upsert, so re-check the row still exists before each write.
8389                        match crate::db::community::community_protocol(community.id()) {
8390                            Ok(Some(_)) => {
8391                                if let Err(e) = crate::db::community::save_community_v2(&cur) {
8392                                    crate::log_warn!("v2: hop checkpoint failed at epoch {} (the walk will redo it): {e}", next.0);
8393                                }
8394                            }
8395                            // Gone mid-walk: stop rather than resurrect it.
8396                            Ok(None) => return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged }),
8397                            Err(e) => crate::log_warn!("v2: hop checkpoint skipped at epoch {}: {e}", next.0),
8398                        }
8399                        advanced = true;
8400                        changed = true;
8401                    }
8402                    Advance::Removed => {
8403                        return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false, wedged: false });
8404                    }
8405                    Advance::Stay => {}
8406                    Advance::StayWedged => {
8407                        // A blob at our locator that will not open: park, never
8408                        // remove, and make the park legible (a silent park is
8409                        // indistinguishable from "no rotation happened").
8410                        wedged = true;
8411                    }
8412                }
8413            }
8414
8415            if !advanced {
8416                break;
8417            }
8418        }
8419
8420        if wedged {
8421            // Legible, and actionable: the prior root still reaches every member,
8422            // so an owner/admin seeing this can counter-rotate before the honest
8423            // chain moves on.
8424            crate::log_warn!(
8425                "[v2:follow {}] a complete rotation delivered a key blob this client cannot open — parked at epoch {} (never treated as a removal)",
8426                &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8427            );
8428            crate::emit_event(
8429                "community_rekey_wedged",
8430                &serde_json::json!({ "community_id": cid_hex, "held_epoch": cur.root_epoch.0 }),
8431            );
8432        }
8433        if !changed {
8434            // A pending severance whose revoke was interrupted must heal on QUIET
8435            // passes too — waiting for the next rotation means a one-shot transport
8436            // blip leaves the community reading Public (and every future ban
8437            // refound-less) until someone happens to rotate again.
8438            retry_pending_severance(transport, &cur).await;
8439            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8440        }
8441        // A leave/delete raced this follow: saving would resurrect the community row
8442        // (the save is an upsert) with no floor rows behind it.
8443        if crate::db::community::community_protocol(community.id())?.is_none() {
8444            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8445        }
8446        crate::db::community::save_community_v2(&cur)?;
8447        // Mark every containment hop FIRST: the marks are what condemn links, and
8448        // both the severance below and the refresh consult them — so the order of
8449        // those two stops mattering (a condemned link is skipped by the refresh
8450        // and buried by the severance, in either order, on any pass).
8451        for hop in &severed_hops {
8452            add_sever_mark(&cid_hex, *hop, now_ms() / 1000);
8453        }
8454        if !severed_hops.is_empty() {
8455            // Someone ELSE ran a Severing Refounding: my links minted below the
8456            // severed epoch are the raid's door — bury them instead of carrying
8457            // them forward. A fresh link minted at-or-after it survives every
8458            // device's adopt, however late (the epoch is the same on all of them).
8459            if let Err(e) = sever_own_links(transport, &cur).await {
8460                crate::log_warn!("[Sever] own-link revocation on adopt failed (retries next follow): {e}");
8461            }
8462        }
8463        // Carry my SURVIVING links across the rotation someone ELSE performed
8464        // (CORD-05 §2). The refounder refreshes only the bundles they can reach —
8465        // their own — so without this every other creator's links keep vending the
8466        // superseded root and drop new joiners onto a dead epoch, which is exactly
8467        // the stranding the stable-URL refresh exists to prevent. Condemned links
8468        // are skipped inside (see the guard there). Best-effort and idempotent.
8469        let _ = refresh_public_links(transport, &cur).await;
8470        // A pending sever mark (mine or adopted) whose revoke never completed
8471        // keeps retrying while condemned links remain (B4).
8472        retry_pending_severance(transport, &cur).await;
8473        Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false, wedged })
8474    })
8475    .await
8476}
8477
8478/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
8479/// address.
8480enum Advance {
8481    /// Adopt this fresh key for `next_epoch`. A BASE adoption also carries the
8482    /// next epoch's Control Plane pair from the blob (CORD-06 §1) — the pk in
8483    /// every 104/136-byte blob, the secret only in a staff recipient's 136. The
8484    /// pair is the BLOB's, never inherited: a legacy 72-byte blob carries
8485    /// neither (that epoch's Control folds at the legacy address), and channel
8486    /// rotations never carry any.
8487    Adopt { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]>, severed: bool, rotator: PublicKey },
8488    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
8489    Removed,
8490    /// No owner rotation extends my held epoch (yet) — keep the current key.
8491    Stay,
8492    /// A complete authorized rotation carried a blob at MY locator that would
8493    /// not open. Never a removal (CORD-06 §2), but never silent either — the
8494    /// caller surfaces it (see [`RekeyFollow::wedged`]).
8495    StayWedged,
8496}
8497
8498/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
8499async fn fetch_rekey_chunks<T: Transport + ?Sized>(
8500    transport: &T,
8501    relays: &[String],
8502    group: &GroupKey,
8503) -> Result<Vec<rekey::RekeyChunk>, String> {
8504    // A rekey plane address is community_root-derived, so ANY member can seal junk
8505    // 3303s there — a flood (or, organically, a large community's own multi-chunk
8506    // rotation past the newest window) could bury the genuine owner/admin rotation
8507    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
8508    // control pager's discipline) so a buried authorized chunk is still recovered;
8509    // the seal + authority filter downstream drops the junk. Bounded — a sustained
8510    // flood past this depth degrades to "adopt one pass late", never a false state.
8511    const REKEY_PAGE: usize = 200;
8512    const REKEY_MAX_PAGES: usize = 6;
8513    let mut out = Vec::new();
8514    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
8515    let mut until: Option<u64> = None;
8516    let mut oldest: Option<u64> = None;
8517    for _ in 0..REKEY_MAX_PAGES {
8518        let query = Query {
8519            kinds: vec![stream::KIND_WRAP],
8520            authors: vec![group.pk_hex()],
8521            until,
8522            limit: Some(REKEY_PAGE),
8523            ..Default::default()
8524        };
8525        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
8526        // shared user-authed client's REQ for a plane's events is CLOSED, so an
8527        // offline rotation catch-up would return nothing and wedge at the old
8528        // epoch. `fetch_plane` rides a connection authed as the plane itself.
8529        let page = transport.fetch_plane(group.keys(), &query, relays).await?;
8530        // A page nobody fully answered must not read as "the plane ends here": the
8531        // short-page break below is a completeness verdict, and drawing it from a
8532        // degraded read is how a client concludes a rotation is absent when it is
8533        // merely unreachable.
8534        let page_complete = page.is_complete();
8535        let wraps = page.events;
8536        let mut fresh = 0usize;
8537        for w in &wraps {
8538            if !seen.insert(w.id) {
8539                continue;
8540            }
8541            fresh += 1;
8542            let at = w.created_at.as_secs();
8543            if oldest.is_none_or(|o| at < o) {
8544                oldest = Some(at);
8545            }
8546            if let Ok(opened) = stream::open_wrap(w, group) {
8547                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
8548                    out.push(chunk);
8549                }
8550            }
8551        }
8552        // Drained, or a same-second wall the pager can't step past (second-granular
8553        // until) — either way stop; the accumulated set is what advance_scope folds.
8554        if fresh == 0 || (wraps.len() < REKEY_PAGE && page_complete) {
8555            break;
8556        }
8557        match oldest {
8558            Some(o) if o > 0 => until = Some(o),
8559            _ => break,
8560        }
8561    }
8562    Ok(out)
8563}
8564
8565/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
8566/// batch pairs the chunks fetched under one root with the continuity to demand of
8567/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
8568/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
8569/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
8570/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
8571/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
8572/// seal authority is the boundary). Among qualifying rotations carrying my blob
8573/// the lexicographically lowest new key wins (convergent). All complete
8574/// candidates without my blob conclude Removed for a KEYED holder only when one
8575/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
8576/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
8577/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
8578async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
8579    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
8580    scope: RekeyScope,
8581    community_id: &crate::community::CommunityId,
8582    rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
8583    rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
8584    admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
8585    signer: &S,
8586    my_xonly: &[u8; 32],
8587    next_epoch: Epoch,
8588) -> Advance {
8589    let mut winners: Vec<rekey::BaseKeyDelivery> = Vec::new();
8590    // Parallel to `winners`: whether each delivery's rotation is a containment
8591    // (Root scope only). Selected by the same lowest-key index, so a same-epoch
8592    // fork adopts the winner's flag and never a loser's.
8593    let mut winner_severed: Vec<bool> = Vec::new();
8594    // Parallel to `winner_severed`: whose Refounding this delivery came from. The
8595    // snapshot authority for the adopted epoch (CORD-02 §5) is exactly this npub.
8596    let mut winner_rotator: Vec<PublicKey> = Vec::new();
8597    let mut saw_complete_candidate = false;
8598    // Did any qualifying rotation actually carry recipients? See the removal gate.
8599    let mut saw_nonempty_candidate = false;
8600    let mut saw_outranking_candidate = false;
8601    let mut my_blob_unopenable = false;
8602    let keyed = batches.iter().any(|(_, held)| held.is_some());
8603    for (chunks, held) in batches {
8604        let rotations = rekey::collect_rotations(chunks);
8605        for r in &rotations {
8606            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 {
8607                continue;
8608            }
8609            if let Some((held_epoch, held_key)) = held {
8610                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
8611                    continue;
8612                }
8613            }
8614            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
8615            // target. An authorized-but-inadmissible rotation (one that excludes
8616            // the owner or a peer/superior the rotator can't act on) is a takeover
8617            // attempt — skip it entirely, so it neither adopts nor concludes a
8618            // removal (it forks; the honest chain wins).
8619            if !admissible(r) {
8620                continue;
8621            }
8622            // Completeness gates the DESTRUCTIVE direction only. Concluding removal
8623            // means "no blob for me anywhere in this rotation", which is only
8624            // knowable with every chunk in hand — so `saw_complete_candidate`, and
8625            // everything downstream of it, still demands the whole set.
8626            //
8627            // ADOPTION does not need it. The blob binds its own (scope, epoch)
8628            // inside the ciphertext, so a key that opens is this rotation's key
8629            // whatever else is unreachable; and admissibility is monotonic in the
8630            // blob set — chunks we have not seen can only ADD recipients, so a
8631            // rotation that looks admissible on the blobs we hold cannot become a
8632            // takeover once the rest arrive. Requiring the full set to adopt made a
8633            // large community's rotation as fragile as its least reachable chunk,
8634            // and the walk is sequential, so that strand blocked every epoch above
8635            // it too. (A rotation that fails admissibility on partial data is still
8636            // skipped, never adopted — the unsafe direction stays closed.)
8637            if r.is_complete() {
8638                saw_complete_candidate = true;
8639                saw_nonempty_candidate |= !r.blobs.is_empty();
8640                saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
8641            }
8642            // EVERY blob at my locator, not just the first. A locator is a public
8643            // index that proves nothing, so anyone may publish a blob at mine;
8644            // taking only the first would let a junk one shadow the real key and
8645            // park a client that actually holds it. First that opens wins.
8646            let mut mine = rekey::find_my_blobs(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch).peekable();
8647            let had_candidate = mine.peek().is_some();
8648            let mut opened_one = false;
8649            for blob in mine {
8650                let opened = match scope {
8651                    // Base blobs are width-declared forms (CORD-06 §1).
8652                    RekeyScope::Root => rekey::open_base_blob(signer, &r.rotator, community_id, r.new_epoch, blob).await,
8653                    RekeyScope::Channel(_) => rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob)
8654                        .await
8655                        .map(|k| rekey::BaseKeyDelivery { new_root: k, control_pk: None, control_root: None }),
8656                };
8657                if let Ok(d) = opened {
8658                    winners.push(d);
8659                    winner_severed.push(scope.id32() == RekeyScope::Root.id32() && r.severed);
8660                    winner_rotator.push(r.rotator);
8661                    opened_one = true;
8662                    break;
8663                }
8664            }
8665            // A blob AT my locator that won't open is not an exclusion (CORD-06 §2:
8666            // removal = NO blob across all chunks). It must never conclude Removed
8667            // below — that is exactly how a pre-split client turned an unreadable
8668            // width into a false self-removal. Stay and keep recovering instead.
8669            if had_candidate && !opened_one {
8670                my_blob_unopenable = true;
8671            }
8672        }
8673    }
8674    if !winners.is_empty() {
8675        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
8676        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
8677        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
8678        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
8679        // the same epoch (separate rotations): every follower converges on the same
8680        // lowest new BASE key — the control pair rides the winner's blobs, never
8681        // compared (CORD-06 §3). A wrap served under two addressing roots can't
8682        // double-count: each rekey wrap opens under exactly one root's group key.
8683        let keys: Vec<[u8; 32]> = winners.iter().map(|d| d.new_root).collect();
8684        let idx = rekey::lowest_key_winner(&keys).expect("winners is non-empty");
8685        let w = &winners[idx];
8686        return Advance::Adopt {
8687            new_key: w.new_root,
8688            control_pk: w.control_pk,
8689            control_root: w.control_root,
8690            severed: winner_severed[idx],
8691            rotator: winner_rotator[idx],
8692        };
8693    }
8694    // A rotation that delivered NO blob to anyone removed nobody. Read literally it
8695    // is "complete, authorized, and contains no blob for me" — which is the removal
8696    // shape — but removal DELETES the community locally, so one empty rotation from
8697    // an authorized rotator would evict the entire membership at once, irreversibly.
8698    // An empty recipient set is a malformed or aborted rotation, never a verdict
8699    // about any individual. Nobody is removed by a rotation that removed everybody.
8700    if saw_complete_candidate && !saw_nonempty_candidate {
8701        return Advance::Stay;
8702    }
8703    if saw_complete_candidate && !my_blob_unopenable && (!keyed || saw_outranking_candidate) {
8704        Advance::Removed
8705    } else if my_blob_unopenable {
8706        Advance::StayWedged
8707    } else {
8708        Advance::Stay
8709    }
8710}
8711
8712// ── Pins (CORD-04 §7) ────────────────────────────────────────────────────────
8713
8714/// A channel's pin list, read from the locally folded head.
8715#[derive(Debug, serde::Serialize)]
8716pub struct ChannelPins {
8717    /// Entries that passed the full §7 verification, wire order (curator's).
8718    pub pins: Vec<super::pins::VerifiedPin>,
8719    /// The head is sealed under a key epoch this client does not hold: the
8720    /// pins exist but are unreadable. Render as unavailable, NEVER as empty —
8721    /// and a writer seeing this MUST NOT publish (it would drop every entry).
8722    pub sealed: bool,
8723    /// Folded head version (0 = no edition has ever folded).
8724    pub version: u64,
8725}
8726
8727/// The channel's stream conversation key at `epoch`, if this client holds the
8728/// deriving secret: a private channel's held per-epoch key, a public channel's
8729/// held base root at that epoch.
8730fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
8731    let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
8732    // A private plane is never derived from the root value (that would address
8733    // the public plane) — mirrors fetch_channel_history's invariant.
8734    if ch.private && ikm == community.community_root {
8735        return None;
8736    }
8737    let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
8738    group.conv_key().as_bytes().try_into().ok()
8739}
8740
8741/// Read a channel's pins from the locally folded head: unseal (private form),
8742/// verify every entry, keep wire order. Local-only — the control follow is what
8743/// moves the head.
8744pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
8745    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
8746    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8747    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
8748    let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
8749        return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
8750    };
8751    let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
8752    let pins = read
8753        .entries
8754        .iter()
8755        .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
8756        .collect();
8757    Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
8758}
8759
8760/// Publish `entries` as the channel's next Pin List edition, in the form the
8761/// channel's folded type mandates, and echo it locally so a follow-up edit
8762/// builds on this write rather than the pre-write fold.
8763async fn publish_pin_list<T: Transport + ?Sized>(
8764    transport: &T,
8765    community: &CommunityV2,
8766    ch: &ChannelV2,
8767    entries: &[super::pins::PinEntry],
8768) -> Result<(), String> {
8769    crate::db::scoped(async move {
8770        let content = if ch.private {
8771            let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
8772            let group = channel_group_key(&key, &ch.id, ch.epoch);
8773            super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
8774        } else {
8775            super::pins::serialize_public_pin_list(entries)?
8776        };
8777        let eid = super::derive::pins_locator(community.id(), &ch.id);
8778        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8779        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
8780        let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
8781        // The version this publish will chain to — mirrors publish_control_edition.
8782        let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
8783            Some((v, _)) => v + 1,
8784            None => 1,
8785        };
8786        crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
8787        publish_control_edition(transport, community, vsk::PINS, &eid, &content).await?;
8788        let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
8789        crate::emit_event(
8790            "community_pins_updated",
8791            &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
8792        );
8793        if let Ok(me) = me_pk() {
8794            use nostr_sdk::prelude::ToBech32;
8795            let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
8796            note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
8797        }
8798        Ok(())
8799    })
8800    .await
8801}
8802
8803/// One centered system row per adopted Pin List edition — "X modified the
8804/// Pins". The id is deterministic on (channel, version), so the publisher's
8805/// echo and every fold that adopts the same edition collapse into one row,
8806/// and a catch-up fold stamps the edition's own time so history sorts true.
8807async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
8808    let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
8809    let inserted = crate::db::events::save_system_event_at(
8810        &event_id,
8811        channel_hex,
8812        crate::stored_event::SystemEventType::PinsModified,
8813        actor_npub,
8814        None,
8815        at_secs,
8816        None,
8817        None,
8818    )
8819    .await
8820    .unwrap_or(false);
8821    if inserted {
8822        crate::emit_event(
8823            "system_event",
8824            &serde_json::json!({
8825                "conversation_id": channel_hex,
8826                "event_id": event_id,
8827                "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
8828                "member_pubkey": actor_npub,
8829            }),
8830        );
8831    }
8832}
8833
8834/// The current entries this writer may build on. Replace-entire cuts sharply
8835/// (§7): an empty view has two innocent causes indistinguishable from an empty
8836/// list, so a writer MUST refuse to build from a list it could not read.
8837fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
8838    let current = read_channel_pins(community, channel_id)?;
8839    if current.sealed {
8840        return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
8841    }
8842    Ok(current.pins.into_iter().map(|p| p.entry).collect())
8843}
8844
8845/// Pin a message: recover its wrap, rebuild its proof, append, republish.
8846///
8847/// The seal is re-fetched from the community relays by the stored wrapper id —
8848/// the DB retains rumors, not seals, and a proof needs the seal verbatim.
8849pub async fn pin_message<T: Transport + ?Sized>(
8850    transport: &T,
8851    community: &CommunityV2,
8852    channel_id: &ChannelId,
8853    rumor_id_hex: &str,
8854) -> Result<(), String> {
8855    crate::db::scoped(async move {
8856        let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
8857        let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
8858
8859        let mut entries = writable_pin_entries(community, channel_id)?;
8860        if entries.len() >= super::pins::PIN_MAX_ENTRIES {
8861            return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
8862        }
8863        // Idempotent: re-pinning an already-pinned message is a no-op, not an error.
8864        if entries
8865            .iter()
8866            .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
8867            .any(|v| v.rumor_id == rumor_id_hex)
8868        {
8869            return Ok(());
8870        }
8871
8872        let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
8873            .ok_or("message not found in this device's history")?;
8874        let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
8875
8876        // Recover the wrap verbatim — Full evidence: a pin is a permanent artifact,
8877        // so don't build it from the first relay to answer.
8878        let wraps = transport
8879            .fetch(
8880                &Query {
8881                    ids: vec![wrap_id.clone()],
8882                    kinds: vec![super::stream::KIND_WRAP],
8883                    limit: Some(1),
8884                    evidence: crate::community::transport::Evidence::Full,
8885                    ..Default::default()
8886                },
8887                &community.relays,
8888            )
8889            .await?;
8890        let wrap = wraps
8891            .iter()
8892            .find(|w| w.id.to_hex() == wrap_id)
8893            .ok_or("the message's wrap is no longer served by this community's relays")?;
8894
8895        // The stored row does not retain the rumor's epoch binding, so re-derive it
8896        // the way history reads do: try the channel's every held plane coordinate,
8897        // current epoch first, until the wrap opens AND carries this rumor. The
8898        // open itself verifies the channel + epoch binding, so a false coordinate
8899        // fails closed rather than mis-attributing.
8900        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8901        let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
8902        if ch.private {
8903            if let Some(k) = ch.key {
8904                coords.push((ch.epoch.0, k));
8905            }
8906            let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
8907            coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
8908        } else {
8909            coords.push((community.root_epoch.0, community.community_root));
8910            let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
8911            coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
8912        }
8913        coords.dedup();
8914
8915        let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
8916        for (epoch, ikm) in coords {
8917            let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
8918            if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
8919                super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
8920            {
8921                if opened.rumor_id.to_hex() == rumor_id_hex {
8922                    let conv: [u8; 32] = group
8923                        .conv_key()
8924                        .as_bytes()
8925                        .try_into()
8926                        .map_err(|_| "conversation key size".to_string())?;
8927                    found = Some((opened, conv));
8928                    break;
8929                }
8930            }
8931        }
8932        let Some((opened, conv_key)) = found else {
8933            return Err("that message is from a key epoch this device no longer holds".to_string());
8934        };
8935
8936        let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
8937            super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
8938            super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
8939            super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
8940        })?;
8941        entries.push(entry);
8942
8943        publish_pin_list(transport, community, &ch, &entries).await
8944    })
8945    .await
8946}
8947
8948/// The deriving secret (ikm) for a channel plane at `epoch` — the same lookup
8949/// `channel_conv_key_at` performs, surfaced for the open path.
8950fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
8951    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8952    if ch.private {
8953        if ch.epoch.0 == epoch {
8954            return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
8955        }
8956        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
8957        crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
8958            .unwrap_or_default()
8959            .into_iter()
8960            .find(|(ep, _)| ep.0 == epoch)
8961            .map(|(_, k)| k)
8962            .ok_or("that message is from a key epoch this device no longer holds".to_string())
8963    } else if community.root_epoch.0 == epoch {
8964        Ok(community.community_root)
8965    } else {
8966        crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
8967            .unwrap_or_default()
8968            .into_iter()
8969            .find(|(ep, _)| ep.0 == epoch)
8970            .map(|(_, k)| k)
8971            .ok_or("that message is from a root epoch this device no longer holds".to_string())
8972    }
8973}
8974
8975/// Unpin a message: the next edition without the entry (§7 — no deletion event).
8976pub async fn unpin_message<T: Transport + ?Sized>(
8977    transport: &T,
8978    community: &CommunityV2,
8979    channel_id: &ChannelId,
8980    rumor_id_hex: &str,
8981) -> Result<(), String> {
8982    let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
8983    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
8984    let entries = writable_pin_entries(community, channel_id)?;
8985    let kept: Vec<super::pins::PinEntry> = entries
8986        .into_iter()
8987        .filter(|e| {
8988            super::pins::verify_pin_entry(e, &ch_hex)
8989                .map(|v| v.rumor_id != rumor_id_hex)
8990                // An entry we can't verify is kept: unpin removes exactly the
8991                // named message, never collateral.
8992                .unwrap_or(true)
8993        })
8994        .collect();
8995    publish_pin_list(transport, community, &ch, &kept).await
8996}
8997
8998/// §7 curator duties: converge the Pin List when a pinned message is deleted
8999/// or edited. Spawned fire-and-forget from ingest — a non-curator, a sealed
9000/// list, or an unpinned target all no-op silently; the duty is voluntary.
9001pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
9002    let channel_hex = channel_hex.to_string();
9003    let target = target_rumor_hex.to_string();
9004    crate::db::spawn_bound(async move {
9005        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
9006        let _ = run_pin_duty(&transport, &channel_hex, &target, edit).await;
9007    });
9008}
9009
9010/// The duty body, transport-injected so tests can drive it end to end.
9011///
9012/// The affected author acts at once; every other PIN_MESSAGES holder waits a
9013/// deterministic 5-25s stagger (hashed from (me, target) — no thundering herd
9014/// of racing editions) and re-reads before publishing, so a duty another
9015/// curator already performed dissolves into a no-op.
9016async fn run_pin_duty<T: Transport + ?Sized>(
9017    transport: &T,
9018    channel_hex: &str,
9019    target: &str,
9020    edit: Option<super::stream::OpenedStream>,
9021) -> Result<(), String> {
9022    use crate::community::roles::Permissions;
9023    let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
9024        return Ok(());
9025    };
9026    let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
9027    let Some(community) = crate::db::community::load_community_v2(&cid)? else {
9028        return Ok(());
9029    };
9030    let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
9031
9032    // Cheap pre-checks before any waiting: pinned target, readable list, held bit.
9033    let read = read_channel_pins(&community, &channel_id)?;
9034    if read.sealed {
9035        return Ok(());
9036    }
9037    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9038        return Ok(());
9039    };
9040    if let Some(ed) = &edit {
9041        // Monotonic: a bundle at or past this revision needs no refresh.
9042        if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9043            return Ok(());
9044        }
9045    }
9046    let me = me_pk()?;
9047    let me_hex = me.to_hex();
9048    let owner_hex = community.owner()?.to_hex();
9049    let roster = crate::db::community::get_community_roles(&cid_hex)?;
9050    if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
9051        return Ok(());
9052    }
9053
9054    if hit.author != me_hex {
9055        let mut h: u32 = 0;
9056        for b in me_hex.bytes().chain(target.bytes()) {
9057            h = h.wrapping_mul(31).wrapping_add(u32::from(b));
9058        }
9059        tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
9060    }
9061
9062    // Re-read after the stagger: another curator's edition may have landed.
9063    let read = read_channel_pins(&community, &channel_id)?;
9064    if read.sealed {
9065        return Ok(());
9066    }
9067    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9068        return Ok(());
9069    };
9070    let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
9071
9072    let entries: Vec<super::pins::PinEntry> = match &edit {
9073        // Deletion: the next edition simply omits the entry (§7 — replace-entire).
9074        None => read
9075            .pins
9076            .iter()
9077            .filter(|p| p.rumor_id != target)
9078            .map(|p| p.entry.clone())
9079            .collect(),
9080        // Edit: the same entry, its bundle refreshed to the newest revision.
9081        Some(ed) => {
9082            if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9083                return Ok(());
9084            }
9085            let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9086            // The edit sealed under the channel's current plane in the common
9087            // (realtime) case; older epochs are tried like every other read.
9088            let mut bundle = None;
9089            let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
9090            let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
9091            epochs.extend(
9092                crate::db::community::held_epoch_keys(&cid_hex, &scope)
9093                    .unwrap_or_default()
9094                    .into_iter()
9095                    .map(|(ep, _)| ep.0),
9096            );
9097            epochs.dedup();
9098            for epoch in epochs {
9099                let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
9100                if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
9101                    bundle = Some(b);
9102                    break;
9103                }
9104            }
9105            let Some(bundle) = bundle else { return Ok(()) };
9106            read.pins
9107                .iter()
9108                .map(|p| {
9109                    let mut entry = p.entry.clone();
9110                    if p.rumor_id == target {
9111                        entry.edit = Some(bundle.clone());
9112                    }
9113                    entry
9114                })
9115                .collect()
9116        }
9117    };
9118
9119    crate::log_info!(
9120        "[pins] duty {} for target {} in channel {}",
9121        if edit.is_some() { "edit-refresh" } else { "omission" },
9122        &target[..12],
9123        &channel_hex[..12]
9124    );
9125    publish_pin_list(transport, &community, &ch, &entries).await
9126}
9127
9128/// Silent owner-side widening: an Admin role published before PIN_MESSAGES
9129/// existed gains the bit, so delegated admins can curate pins in communities
9130/// founded before this build. One edition, idempotent, converges across owner
9131/// devices (both publish the same widened mask as editions of one entity).
9132pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
9133    transport: &T,
9134    community: &CommunityV2,
9135) -> Result<bool, String> {
9136    use crate::community::roles::{Permissions, RoleScope};
9137    let my_pk = me_pk()?;
9138    if community.owner()? != my_pk {
9139        return Ok(false);
9140    }
9141    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9142    let roles = crate::db::community::get_community_roles(&cid_hex)?;
9143    let Some(role) = roles.roles.iter().find(|r| {
9144        matches!(r.scope, RoleScope::Server)
9145            && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
9146            && !r.permissions.contains(Permissions::PIN_MESSAGES)
9147    }) else {
9148        return Ok(false);
9149    };
9150    let mut widened = role.clone();
9151    widened.permissions.0 |= Permissions::PIN_MESSAGES;
9152    set_role(transport, community, &widened).await?;
9153    Ok(true)
9154}
9155
9156#[cfg(test)]
9157mod tests {
9158    // The legacy (pre-split) Control derivation, still reachable for the
9159    // migration-compat paths under test.
9160    use super::super::derive::control_group_key;
9161    use crate::ClientRelayExt;
9162    use nostr_sdk::prelude::FinalizeEvent;
9163    use super::super::super::transport::memory::MemoryRelay;
9164    use super::*;
9165    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
9166
9167    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
9168    fn account_name(n: u32) -> String {
9169        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
9170        let mut acct = String::from("npub1");
9171        let mut v = n as usize;
9172        for _ in 0..58 {
9173            acct.push(B[v % 32] as char);
9174            v = v / 32 + 7;
9175        }
9176        acct
9177    }
9178
9179    /// One test participant: its identity keys and its isolated account DB dir.
9180    struct Actor {
9181        keys: Keys,
9182        account: String,
9183    }
9184
9185    /// Two participants sharing one relay but isolated per-account DBs — the
9186    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
9187    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
9188    /// the per-account id caches, so account A's community is invisible to B
9189    /// until B legitimately joins.
9190    struct TestBed {
9191        _tmp: tempfile::TempDir,
9192        _guard: std::sync::MutexGuard<'static, ()>,
9193        relay: MemoryRelay,
9194        relays: Vec<String>,
9195    }
9196
9197    impl TestBed {
9198        fn new() -> (TestBed, Actor, Actor) {
9199            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
9200            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9201            crate::db::close_database();
9202            crate::db::clear_id_caches();
9203            let tmp = tempfile::tempdir().unwrap();
9204            crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9205
9206            let mk = || {
9207                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9208                let account = account_name(n);
9209                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
9210                crate::db::set_current_account(account.clone()).unwrap();
9211                crate::db::init_database(&account).unwrap();
9212                Actor { keys: Keys::generate(), account }
9213            };
9214            let owner = mk();
9215            let member = mk();
9216            let _ = crate::state::take_nostr_client();
9217            let bed = TestBed {
9218                _tmp: tmp,
9219                _guard: guard,
9220                relay: MemoryRelay::new(),
9221                relays: vec!["wss://r".to_string()],
9222            };
9223            (bed, owner, member)
9224        }
9225
9226        /// Become `actor`: swap the account DB + identity, as a real session swap.
9227        /// Bumps the session generation like production `swap_session` does — so any task a
9228        /// prior actor spawned (e.g. the migration finalize) dies at its std::sync::Arc<crate::db::Session> check
9229        /// instead of racing this actor's DB (a cross-test flake that can't happen in prod).
9230        fn swap_to(&self, actor: &Actor) {
9231            crate::db::close_database();
9232            crate::db::set_current_account(actor.account.clone()).unwrap();
9233            crate::db::init_database(&actor.account).unwrap();
9234            crate::db::clear_id_caches();
9235            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
9236            crate::state::set_my_public_key(actor.keys.public_key());
9237        }
9238    }
9239
9240    /// Legacy single-actor helper (the create/send tests below).
9241    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
9242        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9243        crate::db::close_database();
9244        crate::db::clear_id_caches();
9245        let tmp = tempfile::tempdir().unwrap();
9246        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
9247        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9248        let acct = account_name(n);
9249        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
9250        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9251        crate::db::set_current_account(acct.clone()).unwrap();
9252        crate::db::init_database(&acct).unwrap();
9253        let _ = crate::state::take_nostr_client();
9254        let owner = Keys::generate();
9255        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
9256        crate::state::set_my_public_key(owner.public_key());
9257        (tmp, guard, owner)
9258    }
9259
9260    /// A transport that simulates a session swap landing DURING a fetch await —
9261    /// so a join straddling the fetch sees an invalid session and aborts.
9262    /// Switches to a real second account on the first fetch, then stays out of
9263    /// the way. It BORROWS the relay that built the community — a fresh one
9264    /// would fail verification for want of a control plane, which is not the
9265    /// thing being tested.
9266    struct SwapMidFetch<'a> {
9267        inner: &'a MemoryRelay,
9268        to: String,
9269        armed: std::sync::atomic::AtomicBool,
9270    }
9271
9272    impl<'a> SwapMidFetch<'a> {
9273        fn arm(inner: &'a MemoryRelay) -> Self {
9274            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(88_000);
9275            let to = account_name(N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
9276            std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
9277            Self { inner, to, armed: std::sync::atomic::AtomicBool::new(true) }
9278        }
9279    }
9280
9281    #[async_trait::async_trait]
9282    impl Transport for SwapMidFetch<'_> {
9283        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9284        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9285            self.inner.publish(e, r).await
9286        }
9287        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9288            self.inner.publish_durable(e, r).await
9289        }
9290        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9291            let out = self.inner.fetch(q, r).await;
9292            if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
9293                crate::db::set_current_account(self.to.clone()).unwrap();
9294                crate::db::init_database(&self.to).unwrap();
9295            }
9296            out
9297        }
9298    }
9299
9300    /// Bumps the session generation on the first `publish_durable` — the rekey
9301    /// crate a private-channel create ships before it writes anything locally.
9302    struct SwapMidPublish {
9303        inner: MemoryRelay,
9304    }
9305    #[async_trait::async_trait]
9306    impl Transport for SwapMidPublish {
9307        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9308        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9309            self.inner.publish(e, r).await
9310        }
9311        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9312            let out = self.inner.publish_durable(e, r).await;
9313            crate::db::close_database();
9314            out
9315        }
9316        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9317            self.inner.fetch(q, r).await
9318        }
9319    }
9320
9321    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
9322    /// the production `LiveTransport` union (first-responding relay's batch, no
9323    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
9324    /// the only harness that can exercise the revocation-race ordering.
9325    struct FixedFetch {
9326        events: Vec<Event>,
9327    }
9328    #[async_trait::async_trait]
9329    impl Transport for FixedFetch {
9330        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9331        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9332            Ok(())
9333        }
9334        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9335            Ok(())
9336        }
9337        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9338            Ok(self.events.clone())
9339        }
9340    }
9341
9342    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
9343    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
9344    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
9345        let q = Query {
9346            kinds: vec![stream::KIND_WRAP],
9347            p_tags: vec![me.to_hex()],
9348            k_tags: vec!["3313".to_string()],
9349            ..Default::default()
9350        };
9351        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
9352    }
9353
9354    #[tokio::test]
9355    async fn create_persists_and_reloads_a_v2_community() {
9356        let (_tmp, _guard, owner) = init_test_db();
9357        let relay = MemoryRelay::new();
9358        let relays = vec!["wss://r".to_string()];
9359
9360        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
9361        assert!(created.identity.verify());
9362        assert_eq!(created.owner().unwrap(), owner.public_key());
9363        assert_eq!(created.channels.len(), 1);
9364
9365        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
9366        assert_eq!(
9367            crate::db::community::community_protocol(created.id()).unwrap(),
9368            Some(crate::community::ConcordProtocol::V2)
9369        );
9370        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
9371        assert_eq!(loaded.name, "Vectorville");
9372        assert_eq!(loaded.community_root, created.community_root);
9373        assert_eq!(loaded.identity, created.identity);
9374        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
9375        assert!(!loaded.channels[0].private);
9376
9377        // The genesis control editions + the owner Join landed on the relay.
9378        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
9379    }
9380
9381    #[tokio::test]
9382    async fn owner_sends_and_reads_back_a_message() {
9383        let (_tmp, _guard, _owner) = init_test_db();
9384        let relay = MemoryRelay::new();
9385        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
9386        let general = community.channels[0].id;
9387
9388        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
9389        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
9390        assert_ne!(id1, id2);
9391
9392        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9393        let texts: Vec<String> = page
9394            .iter()
9395            .filter_map(|f| match &f.event {
9396                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9397                _ => None,
9398            })
9399            .collect();
9400        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
9401    }
9402
9403    #[test]
9404    fn send_stamps_never_repeat_within_a_process() {
9405        // Ordering is by the `ms` tag, and an optimized build seals several sends
9406        // per millisecond. Identical stamps fell through to the reader's
9407        // content-derived tiebreak, which knows nothing about which was typed
9408        // first, so a fast sender could watch its own messages come back
9409        // shuffled — the flake in `owner_sends_and_reads_back_a_message` once the
9410        // suite began compiling optimized. Distinctness must not depend on how
9411        // slow the build happens to be.
9412        let stamps: Vec<u64> = (0..5_000).map(|_| next_send_ms()).collect();
9413        for pair in stamps.windows(2) {
9414            assert!(pair[1] > pair[0], "stamps must strictly increase: {} then {}", pair[0], pair[1]);
9415        }
9416    }
9417
9418    #[tokio::test]
9419    async fn same_millisecond_messages_order_by_rumor_id_not_relay_order() {
9420        // Cross-client half: when stamps DO collide (two senders, clock skew),
9421        // every reader must still agree. A bare `ms` sort left ties in whatever
9422        // order the relay served them across pages, so two readers could show the
9423        // same pair in opposite orders. The rumor id is content-derived, so
9424        // pinning ties to it is something every client computes identically.
9425        let (_tmp, _guard, _owner) = init_test_db();
9426        let relay = MemoryRelay::new();
9427        let community = create_community(&relay, "Ties", vec!["wss://r".into()], None).await.unwrap();
9428        let general = community.channels[0].id;
9429
9430        let at = now_ms();
9431        for i in 0..6 {
9432            send_chat_message_at(&relay, &community, &general, &format!("tied {i}"), None, &[], vec![], at)
9433                .await
9434                .unwrap();
9435        }
9436        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9437        let msgs: Vec<(Vec<u8>, String)> = page
9438            .iter()
9439            .filter_map(|f| match &f.event {
9440                ChatEvent::Message { .. } => {
9441                    let o = f.event.opened();
9442                    Some((o.rumor_id.as_bytes().to_vec(), o.rumor.content.clone()))
9443                }
9444                _ => None,
9445            })
9446            .collect();
9447        assert_eq!(msgs.len(), 6, "all six tied messages read back");
9448        let got: Vec<String> = msgs.iter().map(|(_, c)| c.clone()).collect();
9449        let mut by_id = msgs.clone();
9450        by_id.sort_by(|a, b| a.0.cmp(&b.0));
9451        let expected: Vec<String> = by_id.into_iter().map(|(_, c)| c).collect();
9452        assert_eq!(got, expected, "ties resolve by rumor id, never by the relay's serving order");
9453    }
9454
9455    #[tokio::test]
9456    async fn a_rapid_burst_carries_strictly_increasing_stamps() {
9457        // The call-site half: sends must draw from the monotonic stamp, not the
9458        // raw clock. An optimized build seals several per millisecond, and equal
9459        // stamps hand a sender's own ordering to the content tiebreak, which
9460        // knows nothing about typing order.
9461        let (_tmp, _guard, _owner) = init_test_db();
9462        let relay = MemoryRelay::new();
9463        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9464        let general = community.channels[0].id;
9465        for i in 0..8 {
9466            send_message(&relay, &community, &general, &format!("msg {i}")).await.unwrap();
9467        }
9468        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9469        let stamps: Vec<u64> = page
9470            .iter()
9471            .filter_map(|f| match &f.event {
9472                ChatEvent::Message { .. } => Some(f.event.opened().at_ms),
9473                _ => None,
9474            })
9475            .collect();
9476        assert_eq!(stamps.len(), 8);
9477        for pair in stamps.windows(2) {
9478            assert!(pair[1] > pair[0], "a sender's own stamps must strictly increase, got {pair:?}");
9479        }
9480    }
9481
9482    #[tokio::test]
9483    async fn a_second_member_reads_the_public_channel_from_the_root() {
9484        // A member who holds the community_root (via an invite bundle, modeled
9485        // here by cloning the community) reads the owner's public-channel message
9486        // — public channels need no key delivery, they derive from the root.
9487        let (_tmp, _guard, _owner) = init_test_db();
9488        let relay = MemoryRelay::new();
9489        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
9490        let general = community.channels[0].id;
9491        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
9492
9493        // The "member" reconstructs the same read coordinates from the root.
9494        let member_view = community.clone();
9495        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
9496        assert_eq!(page.len(), 1);
9497        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
9498        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
9499    }
9500
9501    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
9502
9503    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
9504        fetch_channel(relay, community, channel, 100)
9505            .await
9506            .unwrap()
9507            .iter()
9508            .filter_map(|f| match &f.event {
9509                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9510                _ => None,
9511            })
9512            .collect()
9513    }
9514
9515    /// History as the BACK-PAGING cursor reads it: every held epoch. Opening a
9516    /// channel deliberately reads only the live planes, so pre-rotation history is
9517    /// reached by paging, which is what a user scrolling up actually does.
9518    async fn all_texts_in<T: crate::community::transport::Transport + ?Sized>(
9519        relay: &T,
9520        community: &CommunityV2,
9521        channel: &ChannelId,
9522    ) -> Vec<String> {
9523        let from_now = now_ms() / 1000 + 3600;
9524        fetch_channel_history(
9525            relay,
9526            community,
9527            channel,
9528            100,
9529            8,
9530            None,
9531            Some(from_now),
9532            crate::community::transport::Evidence::Quorum,
9533            |_| true,
9534        )
9535        .await
9536        .unwrap()
9537        .iter()
9538        .filter_map(|f| match &f.event {
9539            ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9540            _ => None,
9541        })
9542        .collect()
9543    }
9544
9545    #[tokio::test]
9546    async fn direct_invite_full_loop_owner_and_member_converse() {
9547        let (bed, owner, member) = TestBed::new();
9548
9549        // Owner creates a community, posts, and Direct-Invites the member's npub.
9550        bed.swap_to(&owner);
9551        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
9552        let general = community.channels[0].id;
9553        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
9554        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9555
9556        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
9557        bed.swap_to(&member);
9558        assert!(
9559            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9560            "the member does not hold the community before joining"
9561        );
9562        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9563        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9564        assert_eq!(joined.id().0, community.id().0, "joined the same community");
9565        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
9566        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
9567
9568        // The member reads the owner's public-channel history and replies.
9569        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
9570        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
9571
9572        // The owner reads the member's reply.
9573        bed.swap_to(&owner);
9574        assert_eq!(
9575            texts_in(&bed.relay, &community, &general).await,
9576            vec!["owner: welcome!", "member: thanks for the invite"],
9577            "both actors' messages interleave in ms order on the shared channel"
9578        );
9579
9580        // The Guestbook memberlist now folds both participants.
9581        let members = memberlist(&bed.relay, &community).await.unwrap();
9582        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
9583        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
9584        assert_eq!(members.len(), 2);
9585    }
9586
9587    /// Join-time ban gate: an honest client whose npub is on the authorized banlist
9588    /// refuses to join — no Guestbook Join publish, no local write — through the shared
9589    /// accept path every door (direct invite, parked, public link, migration) funnels into.
9590    #[tokio::test]
9591    async fn a_banned_member_is_refused_at_join_time() {
9592        let (bed, owner, member) = TestBed::new();
9593
9594        bed.swap_to(&owner);
9595        let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
9596        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9597        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
9598
9599        bed.swap_to(&member);
9600        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9601        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
9602        assert!(err.contains("banned"), "refusal names the reason: {err}");
9603        assert!(
9604            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9605            "a refused join persists nothing"
9606        );
9607
9608        // The gate is the LAST word only for banned members: an unbanned bystander with
9609        // the same invite path still joins (the gate doesn't over-refuse).
9610        bed.swap_to(&owner);
9611        set_banlist(&bed.relay, &community, &[]).await.unwrap();
9612        bed.swap_to(&member);
9613        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9614        assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
9615    }
9616
9617    /// End-to-end member migration: a member holding a v1 community folds the owner's
9618    /// migration dissolution, opens `m`, joins the v2 twin (ban-gated), and the flip
9619    /// re-parents the stitched channel rows + stamps the fence — all from the single event.
9620    #[tokio::test]
9621    async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
9622        use crate::community::migration;
9623        let (bed, owner, member) = TestBed::new();
9624
9625        // Owner builds the v2 twin (real, verifiable on the shared relay).
9626        bed.swap_to(&owner);
9627        let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
9628        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
9629        let jm = join_material(&v2);
9630
9631        // The member holds a v1 community owned by the SAME owner identity (the migration
9632        // premise) — construct + save it, and hold its server root.
9633        bed.swap_to(&member);
9634        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9635        let v1_cid = v1.id.to_hex();
9636        v1.owner_attestation = Some({
9637            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9638                .finalize(&owner.keys).unwrap().as_json()
9639        });
9640        crate::db::community::save_community(&v1).unwrap();
9641        let v1_channel = v1.channels[0].id.to_hex();
9642
9643        // The dissolution payload: v2 JoinMaterial sealed under the v1 server root.
9644        let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
9645        let signpost = migration::MigrationSignpost {
9646            v2_community_id: v2_hex.clone(),
9647            owner_xonly: owner.keys.public_key().to_hex(),
9648            owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
9649            relays: bed.relays.clone(),
9650            name: "Guild".into(),
9651            primary_channel: v1_channel.clone(),
9652            root_epoch: 0,
9653        };
9654        let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
9655        crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
9656
9657        // Drive the migration: opens m, joins v2 (ban-gated), flips.
9658        let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
9659        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
9660
9661        // Fence: the v1 community is terminally marked, and the v2 twin is held + joined.
9662        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
9663        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
9664        assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
9665        let _ = v1_channel;
9666
9667        // Idempotent: a second drive is a no-op (already flipped).
9668        assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
9669    }
9670
9671    /// The OWNER wizard end-to-end: build the twin (primary channel reuses the v1 id),
9672    /// seal + publish the carrier, flip the owner. Then a MEMBER holding the v1 community
9673    /// folds the same carrier and stitches — proving the channel-STITCH the earlier test
9674    /// couldn't (that twin had mismatched ids).
9675    #[tokio::test]
9676    async fn owner_wizard_then_member_migrate_and_stitch() {
9677        use crate::community::migration;
9678        let (bed, owner, member) = TestBed::new();
9679        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9680
9681        // Owner holds a v1 community (they created it) with one channel.
9682        bed.swap_to(&owner);
9683        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9684        let v1_cid = v1.id.to_hex();
9685        let v1_channel = v1.channels[0].id.to_hex();
9686        v1.owner_attestation = Some({
9687            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9688                .finalize(&owner.keys).unwrap().as_json()
9689        });
9690        crate::db::community::save_community(&v1).unwrap();
9691
9692        // Run the wizard.
9693        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9694        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
9695            "owner's own client flipped to v2");
9696        // The owner's v1 channel row re-parented to the twin (stitch), because the twin's
9697        // primary channel REUSES the v1 channel id.
9698        assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
9699            "owner channel stitched to v2");
9700
9701        // A MEMBER holding the same v1 community folds the carrier and migrates.
9702        bed.swap_to(&member);
9703        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9704        // The member's v1 community must be the SAME id + root the owner published under.
9705        m_v1.id = v1.id;
9706        m_v1.server_root_key = v1.server_root_key.clone();
9707        m_v1.channels[0].id = v1.channels[0].id;
9708        m_v1.owner_attestation = v1.owner_attestation.clone();
9709        crate::db::community::save_community(&m_v1).unwrap();
9710
9711        // Fold the carrier off the relay: the dissolution arm seals, persists the pointer,
9712        // AND auto-drives the flip — the live one-event member experience, no manual step.
9713        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
9714        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
9715        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
9716            "the FOLD ITSELF flipped the member (auto-drive)");
9717        assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
9718            "member holds the v2 twin");
9719        // A manual re-drive is an idempotent no-op.
9720        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
9721    }
9722
9723    /// The wizard records the twin in the cross-device community list, like every other v2
9724    /// join/create path. Sibling devices normally discover the twin by folding the carrier
9725    /// themselves, but one that no longer holds the v1 community has no carrier to fold, so
9726    /// the list is its only route in.
9727    #[tokio::test]
9728    async fn wizard_publishes_the_twin_to_the_cross_device_list() {
9729        use crate::community::migration;
9730        let (bed, owner, _member) = TestBed::new();
9731        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9732
9733        bed.swap_to(&owner);
9734        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9735        let v1_cid = v1.id.to_hex();
9736        v1.owner_attestation = Some({
9737            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9738                .finalize(&owner.keys).unwrap().as_json()
9739        });
9740        crate::db::community::save_community(&v1).unwrap();
9741
9742        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9743
9744        // The twin is live in the published list, so a fresh/carrier-less device finds it.
9745        let list = fetch_fragments(&bed.relay, &bed.relays).await.unwrap()
9746            .expect("the wizard published a community list").list;
9747        assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
9748        // The v1 community is NOT tombstoned there: a tombstone reads as "you left" and
9749        // `sync_community_list` would tear down a sibling's v1 row before it can fold the
9750        // carrier, stranding it. The local `migrated_to` fence is what stops v1 ghosts.
9751        assert!(
9752            !list.tombstones.iter().any(|t| t.community_id == v1_cid),
9753            "migration must not tombstone the v1 community"
9754        );
9755    }
9756
9757    /// The wizard takes the same per-cid claim the member drive does, so a double-fired
9758    /// command (or the owner's own carrier self-fold racing the wizard's phase 2→3 gap)
9759    /// cannot run two wizards: the second would re-mint a twin before the ledger lands
9760    /// (the double-mint orphan) and race its flip against the first.
9761    #[tokio::test]
9762    async fn wizard_refuses_while_a_drive_holds_the_claim() {
9763        use crate::community::migration;
9764        let (bed, owner, _member) = TestBed::new();
9765        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9766
9767        bed.swap_to(&owner);
9768        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9769        let v1_cid = v1.id.to_hex();
9770        v1.owner_attestation = Some({
9771            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9772                .finalize(&owner.keys).unwrap().as_json()
9773        });
9774        crate::db::community::save_community(&v1).unwrap();
9775
9776        // Simulate the concurrent drive holding the cid (what the live carrier fold does).
9777        migration::test_hold_drive_claim(&v1_cid);
9778        let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
9779        assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
9780        // Refused BEFORE minting: no twin, no ledger, nothing to orphan.
9781        assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
9782        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
9783
9784        // Once the drive releases, the wizard runs normally.
9785        migration::test_release_drive_claim(&v1_cid);
9786        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9787        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
9788    }
9789
9790    /// The flip runs UNDER the twin's follow lock, so it can never straddle a follow
9791    /// worker's whole-row save (which deletes channel rows absent from its pre-flip,
9792    /// channel-less struct — pruning exactly the rows the flip just re-parented).
9793    /// Proves the lock actually serializes rather than being a no-op: with the lock held
9794    /// the wizard cannot reach its flip, and it completes once released.
9795    #[tokio::test]
9796    async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
9797        use crate::community::migration;
9798        let (bed, owner, _member) = TestBed::new();
9799        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9800        // Shared across the spawned wizard, so both halves see the same relay state.
9801        let relay = std::sync::Arc::new(MemoryRelay::new());
9802
9803        bed.swap_to(&owner);
9804        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9805        let v1_cid = v1.id.to_hex();
9806        let v1_channel = v1.channels[0].id.to_hex();
9807        v1.owner_attestation = Some({
9808            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9809                .finalize(&owner.keys).unwrap().as_json()
9810        });
9811        crate::db::community::save_community(&v1).unwrap();
9812
9813        // Phase 1 alone, so the twin's id (and therefore its follow lock) is known before
9814        // the flip runs — exactly what a follow worker would have loaded.
9815        let twin = create_migration_twin(
9816            &*relay, "Guild", bed.relays.clone(), None,
9817            (v1.channels[0].id, "general".to_string()),
9818        ).await.unwrap();
9819        let v2_id = twin.identity.community_id;
9820        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
9821        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
9822
9823        // A follow pass is in flight: it holds the lock across its network stage.
9824        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
9825
9826        let wizard = tokio::spawn({
9827            let relay = relay.clone();
9828            let v1 = v1.clone();
9829            async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
9830        });
9831
9832        // The wizard runs its network phases but must BLOCK at the flip.
9833        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
9834        assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
9835        assert!(
9836            crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
9837            "the fence must not be stamped while the follow lock is held"
9838        );
9839
9840        // The follow pass finishes; the flip proceeds.
9841        drop(held);
9842        let flipped = wizard.await.unwrap().unwrap();
9843        assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
9844        assert_eq!(
9845            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
9846            Some(v2_hex.as_str()),
9847            "the channel row is stitched to the twin, not pruned"
9848        );
9849    }
9850
9851    /// THE LYNCHPIN: a banned-but-never-cut v1 member CAN open `m` (they hold the v1
9852    /// root — no read-cut ever rotated it), but the wizard cloned the v1 banlist onto the
9853    /// twin, so the ban-gated accept refuses them: no Guestbook Join, no flip, room stays
9854    /// sealed. This is the exact residual JSKitty accepted, proven enforced.
9855    #[tokio::test]
9856    async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
9857        use crate::community::migration;
9858        let (bed, owner, banned) = TestBed::new();
9859        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9860
9861        // Owner's v1 community with the member on the BANLIST (never read-cut: epoch 0).
9862        bed.swap_to(&owner);
9863        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9864        let v1_cid = v1.id.to_hex();
9865        v1.owner_attestation = Some({
9866            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9867                .finalize(&owner.keys).unwrap().as_json()
9868        });
9869        crate::db::community::save_community(&v1).unwrap();
9870        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
9871
9872        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9873
9874        // The banned member holds the same v1 (same root — never cut) and folds the carrier.
9875        bed.swap_to(&banned);
9876        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9877        m_v1.id = v1.id;
9878        m_v1.server_root_key = v1.server_root_key.clone();
9879        m_v1.channels[0].id = v1.channels[0].id;
9880        m_v1.owner_attestation = v1.owner_attestation.clone();
9881        crate::db::community::save_community(&m_v1).unwrap();
9882        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
9883
9884        // They hold the pointer AND can open `m` — but the drive is REFUSED at the ban gate.
9885        let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
9886        let payload = migration::parse_migration_payload(&raw).unwrap();
9887        assert!(payload.m.is_some());
9888        let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
9889        assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
9890        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
9891        assert!(
9892            crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
9893            "banned member never acquires the v2 twin"
9894        );
9895    }
9896
9897    /// Wizard resume never double-mints: a re-run after the TWIN_MINTED ledger row exists
9898    /// completes on the SAME v2 identity — with a NON-vacuous phase-1b re-run (a sibling
9899    /// channel + a banlist entry crash-recovered end-to-end, sibling stitched). Plus the
9900    /// crash-heal: flip landed but the FLIPPED ledger write didn't → re-run reports success.
9901    #[tokio::test]
9902    async fn wizard_resume_continues_on_the_same_twin() {
9903        use crate::community::migration;
9904        let (bed, owner, banned) = TestBed::new();
9905        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
9906
9907        bed.swap_to(&owner);
9908        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9909        // A second channel + a banned member make the resumed phase-1b tail REAL work.
9910        let mut sibling = v1.channels[0].clone();
9911        sibling.id = crate::community::ChannelId(crate::community::random_32());
9912        sibling.name = "offtopic".into();
9913        v1.channels.push(sibling.clone());
9914        let v1_cid = v1.id.to_hex();
9915        v1.owner_attestation = Some({
9916            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9917                .finalize(&owner.keys).unwrap().as_json()
9918        });
9919        crate::db::community::save_community(&v1).unwrap();
9920        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
9921
9922        // Simulate a crash right after the mint: build the twin + ledger TWIN_MINTED, stop
9923        // BEFORE the sibling channel + banlist clone ever ran.
9924        let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
9925        let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
9926        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
9927
9928        // The re-run resumes onto the SAME identity, re-runs 1b, and completes.
9929        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9930        assert_eq!(v2_hex, minted_hex, "no second twin was minted");
9931        let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
9932        assert_eq!(ledger_v2, minted_hex);
9933        assert_eq!(phase, migration::PHASE_FLIPPED);
9934        // The crash-recovered sibling stitched too, and the banlist clone landed on the wire
9935        // (folding the twin's control plane yields the banned npub).
9936        assert_eq!(
9937            crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
9938            Some(minted_hex.as_str()),
9939            "sibling channel re-parented by the resumed run"
9940        );
9941        let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
9942        let wire_banlist = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
9943            .await
9944            .unwrap()
9945            .banned;
9946        assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
9947            "the resumed banlist clone is folded from the twin's wire control plane");
9948
9949        // Crash-heal: roll the ledger back to CARRIER_PUBLISHED (flip landed, ledger behind)
9950        // → the re-run reports SUCCESS and heals, never "already been migrated".
9951        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
9952        let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
9953        assert_eq!(healed, minted_hex);
9954        let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
9955        assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
9956
9957        // Resume past a SELF-SEAL: a fold sealed the community after the carrier but
9958        // before the flip write (dissolved=1, migrated_to still NULL, ledger at
9959        // CARRIER_PUBLISHED). A wizard resume must NOT read this as a foreign dissolution.
9960        // Reuse THIS bed (a second TestBed would re-lock DB_TEST_GUARD and deadlock) with a
9961        // fresh v1 owned by the same owner.
9962        let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
9963        let v1b_cid = v1b.id.to_hex();
9964        v1b.owner_attestation = Some({
9965            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
9966                .finalize(&owner.keys).unwrap().as_json()
9967        });
9968        crate::db::community::save_community(&v1b).unwrap();
9969        let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
9970        let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
9971        crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
9972        crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); // the self-seal
9973        let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
9974        assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
9975        assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
9976    }
9977
9978    /// The birth refound SEEDS the roster: rolling a genesis (epoch 0) twin to epoch 1 with an
9979    /// explicit member list makes those members fold into the memberlist WITHOUT any of them
9980    /// publishing a Join — the anti-ghost-town seed for not-yet-migrated v1 members (who hold
9981    /// no v2 keys). Genesis had no snapshot power; epoch 1 (owner = minting refounder) does.
9982    #[tokio::test]
9983    async fn birth_refound_seeds_an_explicit_roster() {
9984        let (bed, owner, _m) = TestBed::new();
9985        bed.swap_to(&owner);
9986        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
9987            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
9988        assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
9989        // Two strangers who never join — pure seeded members.
9990        let ghost_a = Keys::generate().public_key();
9991        let ghost_b = Keys::generate().public_key();
9992
9993        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
9994        assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
9995
9996        // The memberlist folds all three from the epoch-1 snapshot, though only the owner
9997        // ever published a Join.
9998        let members = memberlist(&bed.relay, &rolled).await.unwrap();
9999        assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
10000        assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
10001
10002        // The compacted control plane still verifies (owner genesis carried to epoch 1) — a
10003        // fresh joiner at epoch 1 folds it. And a genesis-epoch snapshot has NO power: rolling
10004        // a fresh twin's snapshot only counts because the owner minted epoch 1.
10005        let _ = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
10006            .expect("the epoch-1 twin verifies from its compacted control plane");
10007
10008        // RESUME IDEMPOTENCE: a re-call on the already-refounded twin is a no-op (returns
10009        // epoch 1), never a double-advance to epoch 2 — the crash-between-wire-and-ledger case.
10010        let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
10011        assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
10012        assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
10013    }
10014
10015    /// A banned entry in the seed list must NOT wedge the verify-back: fold_members
10016    /// subtracts the banlist, so a banned seed is never "readable" — the defensive filter drops
10017    /// it before the snapshot, so the refound still completes instead of aborting forever.
10018    #[tokio::test]
10019    async fn birth_refound_ignores_a_banned_seed_entry() {
10020        let (bed, owner, _m) = TestBed::new();
10021        bed.swap_to(&owner);
10022        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10023            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10024        let good = Keys::generate().public_key();
10025        let banned = Keys::generate();
10026        // Ban `banned` on the twin, then hand refound a seed list that (wrongly) includes them.
10027        set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
10028        let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
10029
10030        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
10031            .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
10032        assert_eq!(rolled.root_epoch, Epoch(1));
10033        let members = memberlist(&bed.relay, &rolled).await.unwrap();
10034        assert!(members.contains(&good), "the non-banned seed lands");
10035        assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
10036    }
10037
10038    /// The "late migrator never misses an epoch" property: a SEEDED-but-never-landed
10039    /// member (in the roster only via the birth snapshot, holding no keys, never posted) is a
10040    /// RECIPIENT of a subsequent OWNER refound — so a rotation that happens before they migrate
10041    /// still mints them a rekey blob to walk forward on. Verified by checking the ghost lands
10042    /// in the refound's memberlist-derived recipient set (they get a base-rekey blob).
10043    #[tokio::test]
10044    async fn a_seeded_member_receives_a_later_refound_rekey() {
10045        let (bed, owner, _m) = TestBed::new();
10046        bed.swap_to(&owner);
10047        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10048            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10049        let ghost = Keys::generate();
10050        // Birth refound seeds the ghost (never joins, holds no keys).
10051        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
10052        assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
10053
10054        // A later OWNER refound (epoch 1→2) derives its rekey recipients from memberlist(),
10055        // which folds the snapshot — so the ghost IS a recipient (a base-rekey blob is minted
10056        // for them by construction) AND is re-snapshotted at epoch 2. Surviving in the epoch-2
10057        // memberlist proves both: the refound saw them as a member and carried them forward, so
10058        // a late migrator who opens `m` (epoch 1) can then walk their epoch-2 blob forward.
10059        let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
10060        assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
10061        assert!(
10062            memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
10063            "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
10064        );
10065    }
10066
10067    /// Governance survives migration: a v1 ADMIN is re-granted @admin on the twin (holds
10068    /// MANAGE_ROLES there), while a plain member is not.
10069    #[tokio::test]
10070    async fn v1_admin_stays_admin_across_migration() {
10071        use crate::community::migration;
10072        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10073        let (bed, owner, admin) = TestBed::new();
10074        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10075
10076        bed.swap_to(&owner);
10077        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10078        let v1_cid = v1.id.to_hex();
10079        v1.owner_attestation = Some({
10080            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10081                .finalize(&owner.keys).unwrap().as_json()
10082        });
10083        crate::db::community::save_community(&v1).unwrap();
10084        // v1 governance: one Admin role, granted to `admin`.
10085        let admin_role = Role::admin("a1".repeat(32));
10086        let roles = CommunityRoles {
10087            roles: vec![admin_role.clone()],
10088            grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
10089        };
10090        crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
10091
10092        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10093        let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
10094
10095        // Fold the twin's authority from the wire: the admin holds MANAGE_ROLES, a stranger doesn't.
10096        let authority = fetch_authority(&bed.relay, &twin).await;
10097        assert!(
10098            authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10099            "the v1 admin is an admin on the v2 twin"
10100        );
10101        assert!(
10102            !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10103            "a non-admin gains no authority"
10104        );
10105    }
10106
10107    /// A device that never folded the dissolution tombstone still heals.
10108    ///
10109    /// Live two-device wedge: the control fold is what seals a migrated-away community,
10110    /// and the boot control probe can veto that fold indefinitely (it is `since`-windowed
10111    /// over the CONTROL plane, while the tombstone lives at the DISSOLVED coordinate). The
10112    /// second device therefore sat UNSEALED, which used to exclude it from the sweep
10113    /// (`dissolved = 1`) AND from the flip retry (no pointer) — the one state that most
10114    /// needed probing was the one nothing probed, so it stayed on v1 forever.
10115    #[tokio::test]
10116    async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
10117        use crate::community::migration;
10118        let (bed, owner, member) = TestBed::new();
10119        bed.swap_to(&owner);
10120
10121        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10122        let v1_cid = v1.id.to_hex();
10123        v1.owner_attestation = Some({
10124            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10125                .finalize(&owner.keys).unwrap().as_json()
10126        });
10127        crate::db::community::save_community(&v1).unwrap();
10128
10129        // The owner migrates on their FIRST device: this publishes the carrier tombstone.
10130        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10131        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10132
10133        // A MEMBER's device that holds the v1 community and never folded the tombstone:
10134        // unsealed, pointer-less, unchecked — exactly the wedged shape.
10135        bed.swap_to(&member);
10136        crate::db::community::save_community(&v1).unwrap();
10137        assert!(
10138            !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
10139            "precondition: the wedged device has NOT sealed its v1 row"
10140        );
10141
10142        // It IS a sweep candidate now (the fix); before, `dissolved = 1` excluded it.
10143        assert!(
10144            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10145            "an unsealed, migrated-away v1 must be probed"
10146        );
10147
10148        migration::sweep_dissolved_for_migration(&bed.relay).await;
10149
10150        // The sweep found the carrier, sealed the v1 row, and flipped it to the twin.
10151        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
10152        assert_eq!(
10153            crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
10154            Some(v2_hex.as_str()),
10155            "flipped to the same twin the first device produced"
10156        );
10157        assert!(
10158            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10159            "and the sweep converges — no re-probing forever"
10160        );
10161    }
10162
10163    /// The sweep converges on a PLAIN dissolution (owner-signed, no payload) but a
10164    /// non-owner tombstone (member-mintable) must NOT mark it checked — else a partial-relay
10165    /// probe returning only a stranger's record would permanently stop the sweep before the
10166    /// owner's real carrier is ever fetched.
10167    #[tokio::test]
10168    async fn sweep_marks_checked_only_on_an_owner_tombstone() {
10169        use crate::community::migration;
10170        let (bed, owner, stranger) = TestBed::new();
10171
10172        bed.swap_to(&owner);
10173        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10174        let v1_cid = v1.id.to_hex();
10175        v1.owner_attestation = Some({
10176            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10177                .finalize(&owner.keys).unwrap().as_json()
10178        });
10179        crate::db::community::save_community(&v1).unwrap();
10180
10181        // A STRANGER publishes a (payload-less) tombstone at the dissolved coordinate, and
10182        // the community is locally sealed (as if folded on an old build) but not yet checked.
10183        let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
10184        let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
10185        bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
10186        crate::db::community::set_community_dissolved(&v1_cid).unwrap();
10187
10188        // Sweep: the only record is a stranger's → NOT marked checked (still a candidate).
10189        migration::sweep_dissolved_for_migration(&bed.relay).await;
10190        assert!(
10191            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10192            "a stranger-only probe must not converge the sweep"
10193        );
10194
10195        // Now the OWNER publishes a plain dissolution → sweep marks it checked.
10196        let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
10197        let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
10198        bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
10199        migration::sweep_dissolved_for_migration(&bed.relay).await;
10200        assert!(
10201            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10202            "an owner plain-dissolution converges the sweep"
10203        );
10204    }
10205
10206    /// Wizard preflight refuses before the timelock and for non-owners.
10207    #[tokio::test]
10208    async fn wizard_preflight_gates_timelock_and_ownership() {
10209        use crate::community::migration;
10210        let (bed, owner, _member) = TestBed::new();
10211        bed.swap_to(&owner);
10212        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10213        v1.owner_attestation = Some({
10214            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
10215                .finalize(&owner.keys).unwrap().as_json()
10216        });
10217        crate::db::community::save_community(&v1).unwrap();
10218
10219        // Before the unlock → refused, nothing published.
10220        let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
10221        assert!(err.contains("not unlocked"), "{err}");
10222        assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
10223    }
10224
10225    #[tokio::test]
10226    async fn public_link_full_loop() {
10227        let (bed, owner, member) = TestBed::new();
10228
10229        bed.swap_to(&owner);
10230        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
10231        let general = community.channels[0].id;
10232        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
10233        // Mint a shareable link (a non-stock relay so the fragment carries it).
10234        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10235        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
10236        assert!(link.url.contains('#'), "the fragment carries the token");
10237
10238        // Member joins purely from the URL string.
10239        bed.swap_to(&member);
10240        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10241        assert_eq!(joined.id().0, community.id().0);
10242        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
10243    }
10244
10245    #[test]
10246    fn bundle_of_snapshots_the_held_icon() {
10247        let owner = Keys::generate();
10248        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
10249        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
10250        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
10251        c.icon = Some(icon.clone());
10252        let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
10253        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
10254    }
10255
10256    #[test]
10257    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
10258        // follow_rekeys' fetch fan AND streamauth's plane registration share
10259        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
10260        // MUST include archived roots or an AUTH-gated relay never serves the
10261        // rotation crate → the channel stalls at its old epoch.
10262        let (_tmp, _guard, _owner) = init_test_db();
10263        let cur_root = [9u8; 32];
10264        let cid = crate::community::CommunityId([1u8; 32]);
10265        let cid_hex = cid.to_hex();
10266
10267        // No archives yet → just the current root.
10268        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10269        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
10270
10271        // Archive two prior roots (freshest-first ordering is asserted below).
10272        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
10273        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
10274        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10275        assert_eq!(roots[0], cur_root, "current root leads");
10276        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
10277        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
10278        // Freshest-archived-first (epoch 1 before epoch 0).
10279        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
10280
10281        // A stored root equal to the CURRENT one must not duplicate.
10282        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
10283        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10284        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
10285
10286        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
10287        for e in 3..20u64 {
10288            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
10289        }
10290        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10291        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
10292    }
10293
10294    #[tokio::test]
10295    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
10296        let (bed, owner, member) = TestBed::new();
10297
10298        bed.swap_to(&owner);
10299        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
10300        // The icon lives on the Control Plane, never in the bundle — publish it
10301        // as a metadata edition so the preview must FOLD to see it.
10302        let icon = control::ImageRef {
10303            url: "https://blossom.example/soap".into(),
10304            key: "k".into(),
10305            nonce: "n".into(),
10306            hash: "h".into(),
10307            extra: Default::default(),
10308        };
10309        let mut meta = community.metadata();
10310        meta.icon = Some(icon.clone());
10311        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
10312        // An any-host base — the naddr#fragment payload is domain-agnostic.
10313        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
10314
10315        // A NON-member previews: the real name + the live icon, nothing persisted.
10316        bed.swap_to(&member);
10317        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
10318        assert_eq!(preview.name, "Soapbox");
10319        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
10320        assert!(
10321            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
10322            "previewing must not persist a membership"
10323        );
10324    }
10325
10326    #[tokio::test]
10327    async fn a_previewed_join_reuses_the_verified_fold() {
10328        let (bed, owner, member) = TestBed::new();
10329        bed.swap_to(&owner);
10330        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
10331        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10332
10333        bed.swap_to(&member);
10334        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
10335        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10336        assert_eq!(joined.id().0, community.id().0);
10337        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
10338        // The slot was CONSUMED by the join — proving the handoff path ran (a
10339        // verify re-walk would have left the preview's entry in place).
10340        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
10341    }
10342
10343    #[tokio::test]
10344    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
10345        let (bed, owner, member) = TestBed::new();
10346        bed.swap_to(&owner);
10347        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
10348        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10349
10350        bed.swap_to(&member);
10351        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10352
10353        // Seed from zero: the stored fold equals the authoritative live fold.
10354        assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the seed folds fresh events");
10355        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10356        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
10357        assert!(cursor > 0, "the cursor advanced past zero");
10358        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
10359        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
10360        assert_eq!(stored, live, "stored fold == live fold after the seed");
10361        assert!(stored.contains(&member.keys.public_key()));
10362
10363        // Nothing new on the plane → an idle re-sync folds nothing.
10364        assert!(sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty());
10365
10366        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
10367        bed.swap_to(&owner);
10368        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10369        bed.swap_to(&member);
10370        assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the kick lands incrementally");
10371        assert!(
10372            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
10373            "an owner kick removes the member from the stored fold"
10374        );
10375    }
10376
10377    #[tokio::test]
10378    async fn a_preview_then_revoke_still_refuses_the_join() {
10379        let (bed, owner, member) = TestBed::new();
10380        bed.swap_to(&owner);
10381        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
10382        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10383
10384        // Member previews (warming the verified handoff), THEN the owner revokes.
10385        bed.swap_to(&member);
10386        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
10387        assert_eq!(p.name, "RevokeRace");
10388        bed.swap_to(&owner);
10389        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10390        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10391
10392        // The join MUST refuse: the handoff skips only the root re-verify, never
10393        // the bundle re-fetch that carries the revocation gate.
10394        bed.swap_to(&member);
10395        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10396        assert!(err.contains("revoked"), "got: {err}");
10397    }
10398
10399    #[tokio::test]
10400    async fn a_revoked_link_refuses_to_join() {
10401        let (bed, owner, member) = TestBed::new();
10402        bed.swap_to(&owner);
10403        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
10404        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10405        // Owner retires the link (re-posts the coordinate as a tombstone).
10406        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10407        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10408
10409        bed.swap_to(&member);
10410        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10411        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
10412    }
10413
10414    #[tokio::test]
10415    async fn an_expired_direct_invite_refuses_to_join() {
10416        let (bed, owner, member) = TestBed::new();
10417        bed.swap_to(&owner);
10418        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
10419        // Hand-mint an invite that expired in the past.
10420        let inviter = owner.keys.clone();
10421        let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
10422        bundle.expires_at = Some(1_000); // unix ms, long past
10423        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
10424        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10425
10426        bed.swap_to(&member);
10427        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10428        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10429        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
10430    }
10431
10432    #[tokio::test]
10433    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
10434        // The revocation-durability fix: if ANY signer-valid tombstone is among the
10435        // fetched events, refuse — even when a Live bundle is returned FIRST (the
10436        // production union has no newest-first sort, so a stale relay's Live can lead).
10437        let (bed, owner, member) = TestBed::new();
10438        bed.swap_to(&owner);
10439        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
10440        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10441        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10442
10443        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
10444        // `events.first()` would join the Live; the scan-all fix must refuse.
10445        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
10446
10447        bed.swap_to(&member);
10448        let err = accept_public_link(&union, &link.url).await.unwrap_err();
10449        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
10450    }
10451
10452    #[test]
10453    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
10454        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
10455        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
10456        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
10457        // cap, which trips even earlier — but the count guard is the real defense
10458        // for the single-layer public-link bundle.)
10459        let owner = Keys::generate();
10460        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
10461        let hex = crate::simd::hex::bytes_to_hex_32;
10462        let root = [0x11u8; 32];
10463        let mut bundle = CommunityInvite {
10464            community_id: hex(&identity.community_id.0),
10465            owner: hex(&identity.owner_xonly),
10466            owner_salt: hex(&identity.owner_salt),
10467            community_root: hex(&root),
10468            root_epoch: 0,
10469            control_pk: None,
10470            channels: vec![],
10471            relays: vec!["wss://r".into()],
10472            name: "X".into(),
10473            icon: None,
10474            expires_at: None,
10475            creator_npub: None,
10476            label: None,
10477            extra: Default::default(),
10478        };
10479        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
10480            .map(|i| {
10481                let mut id = [0u8; 32];
10482                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
10483                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
10484            })
10485            .collect();
10486        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
10487    }
10488
10489    #[tokio::test]
10490    async fn a_join_swap_between_fetch_and_save_lands_in_the_joining_account() {
10491        // A public-link accept fetches, then saves. A swap in that window used to
10492        // abort the join outright; the community is now written to the account
10493        // that clicked the link, and the account swapped in gets nothing.
10494        let (bed, owner, member) = TestBed::new();
10495        bed.swap_to(&owner);
10496        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
10497        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10498        bed.swap_to(&member);
10499        let joiner = crate::db::current_session();
10500        let swap_relay = SwapMidFetch::arm(&bed.relay);
10501        accept_public_link(&swap_relay, &link.url).await.unwrap();
10502
10503        assert!(
10504            crate::db::with_session(joiner, async {
10505                crate::db::community::load_community_v2(community.id()).unwrap().is_some()
10506            })
10507            .await,
10508            "the join completed for the account that accepted the link"
10509        );
10510    }
10511
10512    #[tokio::test]
10513    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
10514        // The owner is derived from the self-certifying community_id, so the
10515        // memberlist includes them independent of any Guestbook fetch.
10516        let (_tmp, _guard, owner) = init_test_db();
10517        let relay = MemoryRelay::new();
10518        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
10519        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
10520        // still contains the owner.
10521        let empty = MemoryRelay::new();
10522        let members = memberlist(&empty, &community).await.unwrap();
10523        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
10524    }
10525
10526    #[tokio::test]
10527    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
10528        // The mint path can now produce an expiring invite, and the accept gate
10529        // trips on it (end-to-end through the real service, not a hand-built bundle).
10530        let (bed, owner, member) = TestBed::new();
10531        bed.swap_to(&owner);
10532        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
10533        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
10534            .await
10535            .unwrap();
10536
10537        bed.swap_to(&member);
10538        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10539        assert!(
10540            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
10541            "a minted expiring invite refuses past its deadline"
10542        );
10543    }
10544
10545    #[tokio::test]
10546    async fn a_member_who_leaves_drops_from_the_memberlist() {
10547        let (bed, owner, member) = TestBed::new();
10548        bed.swap_to(&owner);
10549        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
10550        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10551
10552        bed.swap_to(&member);
10553        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10554        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10555        // Let the leave land strictly after the join.
10556        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
10557        leave_community(&bed.relay, &joined).await.unwrap();
10558
10559        bed.swap_to(&owner);
10560        let members = memberlist(&bed.relay, &community).await.unwrap();
10561        assert!(members.contains(&owner.keys.public_key()));
10562        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
10563    }
10564
10565    #[tokio::test]
10566    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
10567        // Multi-account isolation: after the swap, the member's DB holds nothing
10568        // of the owner's community — the dual-stack storage is per-account.
10569        let (bed, owner, member) = TestBed::new();
10570        bed.swap_to(&owner);
10571        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
10572        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
10573
10574        bed.swap_to(&member);
10575        assert!(
10576            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
10577            "the owner's community must be invisible in the member's account DB"
10578        );
10579        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
10580    }
10581
10582    // ── Live control-follow ──────────────────────────────────────────────────
10583
10584    /// Publish an owner-grammar channel edition straight to the control plane,
10585    /// signed by `signer` (the owner for a legit edit, a stranger for the
10586    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
10587    /// The entity's current head `self_hash` on the relay (highest version wins),
10588    /// so a helper can chain a new edition the way a real owner client does.
10589    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
10590        let group = control::ControlPlane::of(&community).write_group().unwrap();
10591        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10592        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
10593        let mut head: Option<(u64, [u8; 32])> = None;
10594        for w in &wraps {
10595            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
10596                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
10597                    head = Some((ed.version, ed.self_hash));
10598                }
10599            }
10600        }
10601        head.map(|(_, h)| h)
10602    }
10603
10604    /// The `vac` a non-owner signer must attach, read off the Grant they were
10605    /// given on the relay (CORD-04 §5). The owner cites nothing. Mirrors what a
10606    /// real client does via `my_authority_citation`, so the fixtures publish the
10607    /// shape Vector actually emits.
10608    async fn cite_on_relay(
10609        relay: &MemoryRelay,
10610        community: &CommunityV2,
10611        signer: &Keys,
10612    ) -> Option<crate::community::edition::AuthorityCitation> {
10613        if community.owner().ok() == Some(signer.public_key()) {
10614            return None;
10615        }
10616        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
10617        let group = control::ControlPlane::of(&community).write_group().unwrap();
10618        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10619        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
10620        let mut head: Option<(u64, [u8; 32])> = None;
10621        for w in &wraps {
10622            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
10623                if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
10624                    head = Some((ed.version, ed.self_hash));
10625                }
10626            }
10627        }
10628        head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
10629    }
10630
10631    async fn publish_channel_edition(
10632        relay: &MemoryRelay,
10633        community: &CommunityV2,
10634        signer: &Keys,
10635        channel_id: &ChannelId,
10636        name: &str,
10637        private: bool,
10638        version: u64,
10639        deleted: bool,
10640    ) {
10641        let group = control::ControlPlane::of(&community).write_group().unwrap();
10642        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
10643        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
10644        let content = serde_json::to_string(&meta).unwrap();
10645        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
10646        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10647        relay.publish(&wrap, &community.relays).await.unwrap();
10648    }
10649
10650    /// A channel edition at an EXPLICIT time, so a paging test can order the plane —
10651    /// the default helper stamps everything 1_000, which leaves newest-first paging
10652    /// arbitrary and any "buried edition" scenario vacuous.
10653    #[allow(clippy::too_many_arguments)]
10654    async fn publish_channel_edition_at(
10655        relay: &MemoryRelay,
10656        community: &CommunityV2,
10657        signer: &Keys,
10658        channel_id: &ChannelId,
10659        name: &str,
10660        version: u64,
10661        at_secs: u64,
10662    ) {
10663        let group = control::ControlPlane::of(community).write_group().unwrap();
10664        let meta = control::ChannelMetadata { name: name.into(), private: false, deleted: None, ..Default::default() };
10665        let content = serde_json::to_string(&meta).unwrap();
10666        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, None, &content, at_secs, None);
10667        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
10668        relay.publish(&wrap, &community.relays).await.unwrap();
10669    }
10670
10671    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
10672    /// to the current relay head like a real owner client.
10673    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
10674        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
10675    }
10676
10677    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
10678    /// relay-side newest-first ordering (paging/eviction scenarios).
10679    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
10680        let group = control::ControlPlane::of(&community).write_group().unwrap();
10681        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
10682        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
10683        let content = serde_json::to_string(&meta).unwrap();
10684        let cite = cite_on_relay(relay, community, signer).await;
10685        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());
10686        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
10687        relay.publish(&wrap, &community.relays).await.unwrap();
10688    }
10689
10690    #[test]
10691    fn metadata_apply_captures_undriven_fields_for_republish() {
10692        let owner = Keys::generate();
10693        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
10694        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
10695        let general = held.channels[0].id;
10696
10697        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
10698        let mut custom = serde_json::Map::new();
10699        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
10700        let mut extra = serde_json::Map::new();
10701        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
10702        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
10703        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
10704        assert_eq!(held.meta_custom, Some(custom.clone()));
10705        assert_eq!(held.meta_extra, extra);
10706        // …and the next local edit's base document republishes them verbatim.
10707        assert_eq!(held.metadata().custom, Some(custom));
10708        assert_eq!(held.metadata().extra, held.meta_extra);
10709
10710        // Same contract for a vsk-2 channel head (voice included).
10711        let mut ch_custom = serde_json::Map::new();
10712        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
10713        let ch_meta = control::ChannelMetadata {
10714            name: "general".into(),
10715            private: false,
10716            voice: Some(true),
10717            deleted: None,
10718            custom: Some(ch_custom.clone()),
10719            extra: Default::default(),
10720        };
10721        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
10722        let ch = held.channel(&general).unwrap();
10723        assert_eq!(ch.voice, Some(true));
10724        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
10725        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
10726        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
10727        assert_eq!(rename.custom, Some(ch_custom));
10728    }
10729
10730    #[test]
10731    fn community_metadata_apply_sets_and_clears_images() {
10732        let owner = Keys::generate();
10733        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
10734        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
10735
10736        let icon = control::ImageRef {
10737            url: "https://blossom.example/i".into(),
10738            key: "k".into(),
10739            nonce: "n".into(),
10740            hash: "h".into(),
10741            extra: Default::default(),
10742        };
10743        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
10744        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
10745        assert_eq!(held.icon.as_ref(), Some(&icon));
10746
10747        // An edition is the FULL document: a head without the icon removes it.
10748        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
10749        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
10750        assert_eq!(held.icon, None);
10751    }
10752
10753    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
10754    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
10755        publish_role_at(relay, community, signer, role, version, 1_000).await
10756    }
10757
10758    /// Page filler: a signed edition that occupies a slot in the pager's window and
10759    /// nothing else. It skips the head/citation relay scans `publish_role` does, which
10760    /// are linear in plane size and make a 500-edition test quadratic.
10761    async fn publish_filler_edition(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
10762        let group = control::ControlPlane::of(&community).write_group().unwrap();
10763        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
10764        let content = crate::community::v2::roles::role_content_json(role).unwrap();
10765        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, None, &content, ts, None);
10766        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
10767        relay.publish(&wrap, &community.relays).await.unwrap();
10768    }
10769
10770    /// `publish_role` with an explicit wall clock. Every other helper stamps 1_000, so a
10771    /// test needing more than one page of history must spread its editions out — a page
10772    /// that is entirely one second wide is a wall `until` cannot step past, and the
10773    /// pager correctly refuses rather than paging forever.
10774    async fn publish_role_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
10775        let group = control::ControlPlane::of(&community).write_group().unwrap();
10776        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
10777        let prev = head_hash_on_relay(relay, community, &role_id).await;
10778        let content = crate::community::v2::roles::role_content_json(role).unwrap();
10779        let cite = cite_on_relay(relay, community, signer).await;
10780        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, ts, cite.as_ref());
10781        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
10782        relay.publish(&wrap, &community.relays).await.unwrap();
10783    }
10784
10785    /// Hand-rotate a community view to `(new_root, epoch)`, minting the fresh
10786    /// split pair a real base rotation would (CORD-06 §3) — the control_root is
10787    /// the new root itself, which is fine for a test.
10788    fn rotate_view(c: &CommunityV2, new_root: [u8; 32], epoch: u64) -> CommunityV2 {
10789        let mut r = c.clone();
10790        r.community_root = new_root;
10791        r.root_epoch = Epoch(epoch);
10792        r.control_root = Some(new_root);
10793        r.control_pk = Some(crate::community::v2::derive::control_signer_group_key(&new_root, r.id(), Epoch(epoch)).pk());
10794        r
10795    }
10796
10797    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
10798    /// Like a compliant granter it delivers the staff write key inside the Grant
10799    /// (CORD-04 §3) whenever the community view holds one — the recipient's
10800    /// derive check drops it when it isn't owed, so attaching unconditionally
10801    /// keeps the helper simple while exercising the real delivery pipeline.
10802    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
10803        let group = control::ControlPlane::of(&community).write_group().unwrap();
10804        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
10805        let prev = head_hash_on_relay(relay, community, &eid).await;
10806        let grant = MemberGrant { member: member.to_hex(), role_ids };
10807        let control_wrap = community.control_root.filter(|_| community.control_pk.is_some()).map(|cr| {
10808            let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(signer.secret_key(), member).unwrap();
10809            let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(community.root_epoch, &cr).as_bytes()).unwrap();
10810            base64_simd::STANDARD.encode_to_string(&payload)
10811        });
10812        let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, control_wrap).unwrap();
10813        let cite = cite_on_relay(relay, community, signer).await;
10814        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
10815        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10816        relay.publish(&wrap, &community.relays).await.unwrap();
10817    }
10818
10819    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
10820    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
10821        let group = control::ControlPlane::of(&community).write_group().unwrap();
10822        let eid = crate::community::v2::derive::banlist_locator(community.id());
10823        let prev = head_hash_on_relay(relay, community, &eid).await;
10824        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
10825        let cite = cite_on_relay(relay, community, signer).await;
10826        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
10827        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10828        relay.publish(&wrap, &community.relays).await.unwrap();
10829    }
10830
10831    fn admin_role(role_id: &str, perms: u64) -> Role {
10832        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
10833    }
10834
10835    /// Hand-roll a LEGACY (pre-split) community exactly as a pre-0.4.2 build
10836    /// created one: the whole Control Plane keyed by the community_root
10837    /// derivation, no control pair anywhere. Publishes the two genesis-shaped
10838    /// editions at the LEGACY address, persists, and folds to seed floors.
10839    async fn create_legacy_community(relay: &MemoryRelay, owner: &Keys, name: &str, relays: Vec<String>) -> CommunityV2 {
10840        let identity = control::CommunityIdentity::mint(&owner.public_key());
10841        let root = crate::community::random_32();
10842        let group = control_group_key(&root, &identity.community_id, Epoch(0));
10843        let meta = control::CommunityMetadata { name: name.into(), relays: relays.clone(), ..Default::default() };
10844        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);
10845        let ch_id = ChannelId(crate::community::random_32());
10846        let ch = control::ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
10847        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);
10848        for r in [&meta_rumor, &ch_rumor] {
10849            let (w, _) = control::seal_control_edition(r, &group, owner, Timestamp::from_secs(1_000)).unwrap();
10850            relay.publish(&w, &relays).await.unwrap();
10851        }
10852        let community = CommunityV2 {
10853            identity,
10854            community_root: root,
10855            root_epoch: Epoch(0),
10856            control_pk: None,
10857            control_root: None,
10858            name: name.into(),
10859            description: None,
10860            icon: None,
10861            banner: None,
10862            meta_custom: None,
10863            meta_extra: Default::default(),
10864            relays,
10865            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() }],
10866            dissolved: false,
10867            created_at_ms: 1_000_000,
10868        };
10869        crate::db::community::save_community_v2(&community).unwrap();
10870        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10871        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &root);
10872        let _ = follow_control(relay, &community).await;
10873        crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
10874    }
10875
10876    #[tokio::test]
10877    async fn a_refounding_mints_the_split_and_delivers_the_pair_by_staffness() {
10878        // CORD-06 §1/§3 end to end: the rotation mints a fresh control pair, a
10879        // STAFF recipient's blob carries the secret (136), a plain member's only
10880        // the address (104), the compaction publishes ONLY at the new split
10881        // address, and each follower adopts exactly what its blob delivered.
10882        let (bed, owner, admin) = TestBed::new();
10883        bed.swap_to(&owner);
10884        let community = create_community(&bed.relay, "SplitRefound", bed.relays.clone(), None).await.unwrap();
10885        assert!(community.control_pk.is_some() && community.control_root.is_some(), "genesis mints the split");
10886
10887        // A staff admin (granted; the Grant delivers the wrap) and a plain
10888        // member (Guestbook Join only — a recipient with no rank).
10889        let rid = "d1".repeat(32);
10890        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10891        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
10892        let plain = Keys::generate();
10893        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10894        let join = guestbook::build_join_rumor(plain.public_key(), None, 1_000);
10895        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &plain, Timestamp::from_secs(1_000)).unwrap();
10896        bed.relay.publish(&w, &community.relays).await.unwrap();
10897
10898        // The admin joins and folds — adopting the write key from their Grant.
10899        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
10900        bed.swap_to(&admin);
10901        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10902        assert_eq!(joined.control_pk, community.control_pk, "the bundle handed over the address");
10903        assert_eq!(joined.control_root, None, "a bundle never carries the secret");
10904        let _ = follow_control(&bed.relay, &joined).await;
10905        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10906        assert_eq!(joined.control_root, community.control_root, "the Grant's control_wrap delivered the secret");
10907
10908        // The owner refounds: a FRESH pair beside the new root.
10909        bed.swap_to(&owner);
10910        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
10911        assert_eq!(refounded.root_epoch, Epoch(1));
10912        assert!(refounded.control_pk.is_some() && refounded.control_root.is_some());
10913        assert_ne!(refounded.control_pk, community.control_pk, "the pair rolls with the root");
10914
10915        // Blob forms by staff-ness: the admin's carries the secret, the plain
10916        // member's only the pk (CORD-06 §1).
10917        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10918        let chunks = fetch_rekey_chunks(&bed.relay, &community.relays, &base_group).await.unwrap();
10919        let rotation = &rekey::collect_rotations(&chunks)[0];
10920        async fn open_as(rotation: &rekey::Rotation, community_id: &crate::community::CommunityId, keys: &Keys) -> rekey::BaseKeyDelivery {
10921            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");
10922            let signer = crate::signer::ActiveSigner::Keys(keys.clone());
10923            rekey::open_base_blob(&signer, &rotation.rotator, community_id, Epoch(1), blob).await.unwrap()
10924        }
10925        let admin_delivery = open_as(rotation, community.id(), &admin.keys).await;
10926        assert_eq!(admin_delivery.control_root, refounded.control_root, "staff blob = 136, secret included");
10927        let plain_delivery = open_as(rotation, community.id(), &plain).await;
10928        assert_eq!(plain_delivery.control_pk.map(|p| PublicKey::from_slice(p.as_slice()).unwrap()), refounded.control_pk);
10929        assert_eq!(plain_delivery.control_root, None, "member blob = 104, never the secret");
10930
10931        // The compaction lives ONLY at the new split address — never mirrored to
10932        // the new epoch's legacy-derived address (CORD-06 §3).
10933        let legacy_new = control_group_key(&refounded.community_root, community.id(), Epoch(1));
10934        let q = |pk: String| Query { kinds: vec![stream::KIND_WRAP], authors: vec![pk], limit: Some(50), ..Default::default() };
10935        assert!(bed.relay.fetch(&q(legacy_new.pk_hex()), &community.relays).await.unwrap().is_empty(), "no legacy mirror");
10936        assert!(!bed.relay.fetch(&q(refounded.control_pk.unwrap().to_hex()), &community.relays).await.unwrap().is_empty(), "the compaction rides the split address");
10937
10938        // The admin's follow adopts the whole pair from their 136-byte blob.
10939        bed.swap_to(&admin);
10940        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
10941        let adopted = follow.updated.expect("the admin adopts the rotation");
10942        assert_eq!(adopted.root_epoch, Epoch(1));
10943        assert_eq!(adopted.control_pk, refounded.control_pk);
10944        assert_eq!(adopted.control_root, refounded.control_root, "staff crossing a rotation get the new secret in the blob");
10945    }
10946
10947    #[tokio::test]
10948    async fn a_rescue_lets_a_stranded_member_walk_every_missed_hop() {
10949        // A member forgotten by two consecutive rotations is wedged at epoch 0: their
10950        // client derives base_rekey_group(root_0, cid, 1), finds no blob, and can never
10951        // even compute where epoch 2 lives. The rescue publishes the blobs that should
10952        // have existed, correlated into the ORIGINAL rotations — then the walk just works.
10953        let (_tmp, _guard, _owner) = init_test_db();
10954        let relay = MemoryRelay::new();
10955        let community = create_community(&relay, "Stranded", vec!["wss://r".into()], None).await.unwrap();
10956        let stranded = Keys::generate();
10957
10958        // Two rotations that never vend to `stranded` (they are in no fold: never
10959        // joined the wire, never posted, not in the local store).
10960        let e1 = refound_community(&relay, &community, &[]).await.unwrap();
10961        let e2 = refound_community(&relay, &e1, &[]).await.unwrap();
10962        assert_eq!(e2.root_epoch, Epoch(2));
10963
10964        // Wedged: no blob for them at the only plane they can derive.
10965        let hop1 = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10966        let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
10967        let r1 = &rekey::collect_rotations(&chunks)[0];
10968        assert!(
10969            rekey::find_my_blob(&r1.blobs, &r1.rotator.to_bytes(), &stranded.public_key().to_bytes(), r1.scope, r1.new_epoch).is_none(),
10970            "genuinely stranded before the rescue"
10971        );
10972
10973        let report = rescue_stranded_members(&relay, &e2, &[stranded.public_key()]).await.unwrap();
10974        assert_eq!(report.hops, 2, "one catch-up per missed hop");
10975        assert_eq!(report.hops_skipped, 0);
10976
10977        // Walk exactly as a client would: derive each hop's plane from the PREVIOUS
10978        // root, find our blob inside the correlated rotation, open, adopt, repeat.
10979        let signer = crate::signer::ActiveSigner::Keys(stranded.clone());
10980        let mut held_root = community.community_root;
10981        for e in 1..=2u64 {
10982            let group = base_rekey_group_key(&held_root, community.id(), Epoch(e));
10983            let chunks = fetch_rekey_chunks(&relay, &community.relays, &group).await.unwrap();
10984            let rotations = rekey::collect_rotations(&chunks);
10985            let rot = rotations
10986                .iter()
10987                .find(|r| {
10988                    rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &stranded.public_key().to_bytes(), r.scope, r.new_epoch).is_some()
10989                })
10990                .expect("a rotation now carries our blob");
10991            let blob = rekey::find_my_blob(&rot.blobs, &rot.rotator.to_bytes(), &stranded.public_key().to_bytes(), rot.scope, rot.new_epoch).unwrap();
10992            let delivery = rekey::open_base_blob(&signer, &rot.rotator, community.id(), Epoch(e), blob).await.unwrap();
10993            held_root = delivery.new_root;
10994        }
10995        assert_eq!(held_root, e2.community_root, "the walk ends on the live root");
10996
10997        // Merge safety on DEPLOYED clients: the catch-up extended the original chunk
10998        // set (disjoint indices), so everything folds as ONE complete rotation and no
10999        // original recipient's blob was displaced — a slot collision would fold as
11000        // their removal.
11001        let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
11002        let rotations = rekey::collect_rotations(&chunks);
11003        assert_eq!(rotations.len(), 1, "catch-up merged into the original rotation, not a fork");
11004        assert!(rotations[0].is_complete(), "the extended set still reads complete");
11005        let owner_pk = e2.owner().unwrap();
11006        assert!(
11007            rekey::find_my_blob(&rotations[0].blobs, &rotations[0].rotator.to_bytes(), &owner_pk.to_bytes(), rotations[0].scope, rotations[0].new_epoch).is_some(),
11008            "the original recipient's blob survived the extension"
11009        );
11010
11011        // And the outside world counts them again: the snapshot addendum seeded them
11012        // onto the current epoch's guestbook plane.
11013        let wire = wire_guestbook_members(&relay, &e2).await.unwrap();
11014        assert!(wire.contains(&stranded.public_key()), "the invite page's fold now includes them");
11015    }
11016
11017    #[tokio::test]
11018    async fn a_non_owner_refounders_snapshot_seeds_the_roster() {
11019        // CORD-02 §5: a snapshot is honored "only from the npub whose Refounding
11020        // minted that epoch". Vector read that as the OWNER always, so a moderation
11021        // bot's containment rolled the epoch and vended the keys while its survivor
11022        // list was thrown away — every client then folded its own accumulated Joins,
11023        // keeping members the containment had cut and disagreeing with each other.
11024        let (_tmp, _guard, _owner) = init_test_db();
11025        let relay = MemoryRelay::new();
11026        let community = create_community(&relay, "MintedBy", vec!["wss://r".into()], None).await.unwrap();
11027        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11028        let owner = community.owner().unwrap();
11029
11030        // An admin (NOT the owner) refounded to epoch 1 — this device verified and
11031        // adopted that rotation, so it knows who minted it.
11032        let admin = Keys::generate();
11033        let mut at_epoch_1 = community.clone();
11034        at_epoch_1.root_epoch = Epoch(1);
11035        record_epoch_minter(&cid_hex, 1, &admin.public_key().to_hex());
11036
11037        // Someone the containment cut: joined at the old epoch, never banned, and
11038        // absent from the survivor list the refounder published.
11039        let cut = Keys::generate().public_key();
11040        let join = guestbook::GuestbookEvent {
11041            epoch: None, observed_at: None, rumor_id: [11u8; 32],
11042            entry: guestbook::GuestbookEntry::Join { member: cut, invited_by: None, at_ms: 1_000 },
11043        };
11044        let snapshot = |by: &Keys, id: u8| guestbook::GuestbookEvent {
11045            epoch: Some(1), observed_at: None, rumor_id: [id; 32],
11046            entry: guestbook::GuestbookEntry::Snapshot {
11047                refounder: by.public_key(),
11048                members: vec![owner],           // survivors: the cut member is NOT here
11049                snapshot_id: [id; 32],
11050                chunk: (1, 1),
11051                at_ms: 5_000,                   // newer than the Join it supersedes
11052            },
11053        };
11054
11055        // A stranger's snapshot must change nothing — authority is the minter, not
11056        // whoever manages to write a 3312.
11057        let stranger = Keys::generate();
11058        crate::db::community::set_guestbook(&cid_hex, &[join.clone(), snapshot(&stranger, 0x51)], 6).unwrap();
11059        assert!(
11060            stored_memberlist(&at_epoch_1).unwrap().contains(&cut),
11061            "a snapshot from an npub that minted nothing seeds no roster"
11062        );
11063
11064        // The actual refounder's snapshot IS the guest list.
11065        crate::db::community::set_guestbook(&cid_hex, &[join, snapshot(&admin, 0x52)], 6).unwrap();
11066        let members = stored_memberlist(&at_epoch_1).unwrap();
11067        assert!(
11068            !members.contains(&cut),
11069            "the refounder's survivor list is adopted, so a cut member stops being one"
11070        );
11071        assert!(members.contains(&owner), "a survivor the snapshot names stays");
11072    }
11073
11074    /// A moderation report quotes what was said, so it may only reach someone who
11075    /// could have read the room it was said in. Community power is NOT that
11076    /// question — BAN says what a moderator may do to people, never which rooms
11077    /// they may see — and a bot holding every key would otherwise forward a
11078    /// private channel's contents to whoever holds the ban bit.
11079    #[tokio::test]
11080    async fn reading_a_private_channel_needs_its_own_role_not_community_power() {
11081        let (bed, owner, mod_without_access) = TestBed::new();
11082        bed.swap_to(&owner);
11083        let community = create_community(&bed.relay, "Rooms", bed.relays.clone(), None).await.unwrap();
11084        let public = community.channels[0].id;
11085        let private = create_private_channel(&bed.relay, &community, "back-room").await.unwrap();
11086        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11087        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11088
11089        use nostr_sdk::prelude::ToBech32;
11090        let them = mod_without_access.keys.public_key();
11091        let them_npub = them.to_bech32().unwrap();
11092
11093        // Give them the fullest COMMUNITY powers there are. It must buy nothing
11094        // in a room they were never admitted to.
11095        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa7; 32]);
11096        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
11097        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![admin_rid], 1).await;
11098        follow_control(&bed.relay, &community).await.unwrap();
11099        assert!(
11100            crate::VectorCore.member_has_permission(&cid_hex, &them_npub, Permissions::BAN).unwrap(),
11101            "they really do hold BAN"
11102        );
11103
11104        let pub_hex = crate::simd::hex::bytes_to_hex_32(&public.0);
11105        let priv_hex = crate::simd::hex::bytes_to_hex_32(&private.0);
11106        assert!(
11107            crate::VectorCore.member_can_read_channel(&cid_hex, &pub_hex, &them_npub).unwrap(),
11108            "a public channel is the community's"
11109        );
11110        assert!(
11111            !crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11112            "BAN is not a key: community power must never open a private room"
11113        );
11114
11115        // The access role is what opens it — and it carries no powers at all.
11116        grant_channel_access(&bed.relay, &community, &private, &them).await.unwrap();
11117        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11118        let _ = follow_control(&bed.relay, &community).await;
11119        assert!(
11120            crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11121            "the channel-scoped grant is what admits them"
11122        );
11123    }
11124
11125    /// An empty retain means "cut nobody", never "keep nobody".
11126    ///
11127    /// The console's "Rotate keys" sends an empty retain when nothing is unticked.
11128    /// `members_to_remove` reads that correctly and removes nobody — but the same
11129    /// empty list also became an ALLOW-LIST, and an allow-list keeps only the
11130    /// rotating device and the owner. So a plain rotation vended to two people and
11131    /// stranded everyone else at the previous epoch, permanently, presenting as
11132    /// members vanishing some time later nowhere near the button that did it.
11133    ///
11134    /// The two paths are pinned here against each other, because they take the same
11135    /// empty list and must disagree about what it means.
11136    #[tokio::test]
11137    async fn an_empty_retain_cuts_nobody_rather_than_keeping_nobody() {
11138        let (bed, owner, member) = TestBed::new();
11139        bed.swap_to(&owner);
11140        let community = create_community(&bed.relay, "EmptyRetain", bed.relays.clone(), None).await.unwrap();
11141
11142        // A third party who is neither the rotator nor the owner — the only kind of
11143        // member an empty allow-list silently drops.
11144        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11145        bed.swap_to(&member);
11146        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11147        bed.swap_to(&owner);
11148        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()));
11149
11150        // The plain reading: cut nobody, vend to everybody. This is what the button
11151        // must reach. (The allow-list reading of the same empty list is covered by
11152        // `a_rotation_that_would_strand_staff_refuses_to_publish`.)
11153        refound_community(&bed.relay, &community, &[]).await.expect("cutting nobody must succeed");
11154        assert!(
11155            vended_to(&bed, &community, &member.keys).await,
11156            "a plain rotation must vend to a member who is neither the rotator nor the owner"
11157        );
11158    }
11159
11160    /// The console's rotate button reaches the "cut nobody" path, not the allow-list
11161    /// one. Pinned from source: the two are one `if` apart and read the same empty
11162    /// list in opposite directions, which is exactly how this shipped.
11163    #[test]
11164    fn an_empty_retain_does_not_become_an_allow_list() {
11165        let src = include_str!("../../lib.rs");
11166        let at = src.find("pub async fn purge_and_refound").expect("purge_and_refound");
11167        let body = &src[at..at + 3000];
11168        let guard = body.find("if keep.is_empty()").expect("an empty retain must branch away from the allow-list");
11169        let retaining = body.find("refound_community_retaining").expect("the allow-list call");
11170        assert!(guard < retaining, "the branch has to come BEFORE the allow-list call");
11171    }
11172
11173    /// If the recipients would strand a granted member, publish NOTHING.
11174    ///
11175    /// A member missed at a hop is stranded there permanently, and every other
11176    /// client goes on reading them as staff — silent, irreversible without the
11177    /// rescue primitive, and only noticed because a human saw the member list
11178    /// shrink. So the rotation refuses instead, exactly as the entity carry-forward
11179    /// already refuses a floored head it cannot serve.
11180    #[tokio::test]
11181    async fn a_rotation_that_would_strand_staff_refuses_to_publish() {
11182        let (bed, owner, staffer) = TestBed::new();
11183        bed.swap_to(&owner);
11184        let community = create_community(&bed.relay, "NoSilentSever", bed.relays.clone(), None).await.unwrap();
11185        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11186        let them = staffer.keys.public_key();
11187
11188        let rid = crate::simd::hex::bytes_to_hex_32(&[0xb6; 32]);
11189        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11190        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11191        follow_control(&bed.relay, &community).await.unwrap();
11192
11193        // An operator purge whose allow-list forgets an admin: the recipient set
11194        // ends up without somebody the roster still grants a role to.
11195        let err = refound_community_retaining(&bed.relay, &community, &[], &[owner.keys.public_key()])
11196            .await
11197            .expect_err("must refuse rather than strand staff");
11198        assert!(
11199            err.contains("stranded"),
11200            "the refusal has to say what it protected, got: {err}"
11201        );
11202
11203        // And nothing was published: the epoch did not move.
11204        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11205        assert_eq!(held.root_epoch, community.root_epoch, "no state published");
11206        assert!(!vended_to(&bed, &community, &staffer.keys).await, "no half-rotation left on the plane");
11207        let _ = cid_hex;
11208    }
11209
11210    /// The backstop protects a SILENT granted member. An ACTIVE one is the gap.
11211    ///
11212    /// `fold_members` seeds granted members with `observed.entry(pk).or_insert(0)`,
11213    /// and ts 0 is what dodges `stale_pre_refound`. A staffer who actually talks
11214    /// already sits in `observed` with a REAL timestamp, so `or_insert` leaves it
11215    /// alone — and if some earlier refounding's snapshot omitted them while their
11216    /// last word predates its cut, they are suppressed despite holding a grant.
11217    ///
11218    /// That is Sentire, 2026-08-26: admin, constantly posting, vended no key.
11219    #[tokio::test]
11220    async fn a_granted_member_who_talks_is_not_dropped_by_an_old_snapshot() {
11221        let (bed, owner, staffer) = TestBed::new();
11222        bed.swap_to(&owner);
11223        let community = create_community(&bed.relay, "TalkingAdmin", bed.relays.clone(), None).await.unwrap();
11224        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11225        let them = staffer.keys.public_key();
11226
11227        let rid = crate::simd::hex::bytes_to_hex_32(&[0xb5; 32]);
11228        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11229        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11230        follow_control(&bed.relay, &community).await.unwrap();
11231
11232        // They joined and spoke — so `observed` holds a REAL timestamp for them,
11233        // not the 0 the grant backstop would have inserted.
11234        let spoke_at = 5_000u64;
11235        let join = guestbook::GuestbookEvent {
11236            epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x31; 32],
11237            entry: guestbook::GuestbookEntry::Join { member: them, invited_by: None, at_ms: spoke_at },
11238        };
11239        // A PRIOR refounding whose snapshot omitted them, cutting after they spoke.
11240        let snapshot = guestbook::GuestbookEvent {
11241            epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x32; 32],
11242            entry: guestbook::GuestbookEntry::Snapshot {
11243                refounder: owner.keys.public_key(),
11244                members: vec![owner.keys.public_key()],   // them: omitted
11245                snapshot_id: [0x32; 32],
11246                chunk: (1, 1),
11247                at_ms: spoke_at + 1_000,
11248            },
11249        };
11250        crate::db::community::set_guestbook(&cid_hex, &[join, snapshot], 9).unwrap();
11251
11252        assert!(
11253            stored_memberlist(&community).unwrap().contains(&them),
11254            "a grant is proof of membership — an old snapshot must not shed the staff it forgot"
11255        );
11256
11257        let _ = refound_community(&bed.relay, &community, &[]).await.unwrap();
11258        assert!(
11259            vended_to(&bed, &community, &staffer.keys).await,
11260            "and the rotation must vend to them, or they are stranded holding a grant"
11261        );
11262    }
11263
11264    /// A GRANT is proof of membership, so a rotation must vend to it.
11265    ///
11266    /// Sentire's shape, 2026-08-26: an admin whose Join had been superseded by an
11267    /// earlier refounding's snapshot fell out of BOTH folds the recipient union is
11268    /// built from, held a valid admin grant the whole time, and was vended no key.
11269    /// It could then only derive `held + 1`, so it was stranded permanently while
11270    /// still appearing to be staff to everyone else.
11271    ///
11272    /// `fold_members` already treats a grant as consensus-complete proof — "this is
11273    /// what keeps a Refounding from severing a lurking admin". The rotation has to
11274    /// agree with it.
11275    #[tokio::test]
11276    async fn a_rotation_vends_to_a_granted_member_the_folds_forgot() {
11277        let (bed, owner, staffer) = TestBed::new();
11278        bed.swap_to(&owner);
11279        let community = create_community(&bed.relay, "LurkingAdmin", bed.relays.clone(), None).await.unwrap();
11280        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11281        let them = staffer.keys.public_key();
11282
11283        // Staff by GRANT, with no Join anywhere — the lurking admin the backstop
11284        // exists for. Never posted, never announced.
11285        let rid = crate::simd::hex::bytes_to_hex_32(&[0xb4; 32]);
11286        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11287        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11288        follow_control(&bed.relay, &community).await.unwrap();
11289        assert!(
11290            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&them.to_hex()),
11291            "they hold the grant"
11292        );
11293
11294        // An empty guestbook is the whole point: neither fold can see them.
11295        crate::db::community::set_guestbook(&cid_hex, &[], 1).unwrap();
11296
11297        let _rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11298
11299        // The blob is the answer. A member absent from the rotation's recipients is
11300        // stranded at the previous epoch for good — they can only ever derive
11301        // `held + 1`, so no later rotation reaches them.
11302        assert!(
11303            vended_to(&bed, &community, &staffer.keys).await,
11304            "a rotation removing nobody must vend to a granted member, or it severs its own staff"
11305        );
11306    }
11307
11308    /// "Rotate keys" with nobody unticked must remove NOBODY — and must vend to
11309    /// everyone, not merely leave them on a list. A rotation that forgets a
11310    /// member strands them at the previous epoch permanently: they can only
11311    /// derive `held + 1`, so no later rotation can ever reach them.
11312    #[tokio::test]
11313    async fn a_rotation_that_removes_nobody_vends_to_everybody() {
11314        let (bed, owner, member) = TestBed::new();
11315        bed.swap_to(&owner);
11316        let community = create_community(&bed.relay, "KeepAll", bed.relays.clone(), None).await.unwrap();
11317
11318        // A second member joins for real, via the invite path.
11319        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11320        bed.swap_to(&member);
11321        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11322        bed.swap_to(&owner);
11323        let before = memberlist(&bed.relay, &community).await.unwrap();
11324        assert!(before.contains(&member.keys.public_key()), "present before the rotation");
11325
11326        // Rotate keeping everyone — the empty `removed` the console sends when
11327        // nothing is unticked.
11328        let rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11329        assert_eq!(rotated.root_epoch.0, community.root_epoch.0 + 1, "the epoch rolled");
11330
11331        let after = memberlist(&bed.relay, &community).await.unwrap();
11332        assert!(
11333            after.contains(&member.keys.public_key()),
11334            "a rotation removing nobody must keep everybody: {before:?} -> {after:?}"
11335        );
11336
11337        // And the member must actually be able to WALK to the new epoch, which is
11338        // the half a memberlist cannot show.
11339        bed.swap_to(&member);
11340        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11341        let follow = follow_rekeys(&bed.relay, &held, &crate::db::current_session()).await.unwrap();
11342        assert!(!follow.self_removed, "they were not excluded");
11343        let now = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11344        assert_eq!(now.root_epoch.0, rotated.root_epoch.0, "they adopted the new epoch — a blob was vended to them");
11345    }
11346
11347    /// A revocation has to reach the NETWORK, not just the revoker.
11348    ///
11349    /// Every other client decides who is staff by folding the control plane, so
11350    /// an owner whose demotion never published is the only person who believes
11351    /// it happened — and the demoted admin keeps every power everywhere else,
11352    /// which is the worst possible direction for this to fail in.
11353    #[tokio::test]
11354    async fn revoking_admin_publishes_where_everyone_else_reads_it() {
11355        let (bed, owner, member) = TestBed::new();
11356        bed.swap_to(&owner);
11357        let community = create_community(&bed.relay, "Demotion", bed.relays.clone(), None).await.unwrap();
11358        let them = member.keys.public_key();
11359        let them_hex = them.to_hex();
11360
11361        grant_admin(&bed.relay, &community, &them).await.unwrap();
11362        let view = fetch_authority(&bed.relay, &community).await;
11363        assert!(view.roles.is_admin(&them_hex), "granted, and readable from the plane");
11364
11365        revoke_admin(&bed.relay, &community, &them).await.unwrap();
11366
11367        // The plane, NOT the local row: this is the copy every peer folds.
11368        let view = fetch_authority(&bed.relay, &community).await;
11369        assert!(
11370            !view.roles.is_admin(&them_hex),
11371            "the demotion must be on the plane, or only the owner believes it"
11372        );
11373
11374        // And the same answer through the public API a bot or client would ask.
11375        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11376        use nostr_sdk::prelude::ToBech32;
11377        assert!(
11378            !crate::VectorCore
11379                .member_has_permission(&cid_hex, &them.to_bech32().unwrap(), crate::community::roles::Permissions::BAN)
11380                .unwrap(),
11381            "a demoted admin holds no powers"
11382        );
11383    }
11384
11385    /// Authority must fail CLOSED. A bot gating a chat command on this is the
11386    /// only thing standing between "a moderator asked" and "anyone in the room
11387    /// asked", so an unfolded, empty or unreadable roster has to authorise
11388    /// nobody — `has_permission` finds no grant and denies. The owner is the one
11389    /// exception, and is answered without consulting the roster at all.
11390    #[tokio::test]
11391    async fn permission_is_refused_to_everyone_the_roster_does_not_name() {
11392        let (_tmp, _guard, _owner) = init_test_db();
11393        let relay = MemoryRelay::new();
11394        let community = create_community(&relay, "Authority", vec!["wss://r".into()], None).await.unwrap();
11395        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11396        use nostr_sdk::prelude::ToBech32;
11397        let owner_npub = community.owner().unwrap().to_bech32().unwrap();
11398        let stranger = Keys::generate().public_key().to_bech32().unwrap();
11399
11400        use crate::community::roles::Permissions;
11401        for p in [Permissions::KICK, Permissions::BAN, Permissions::MANAGE_ROLES] {
11402            assert!(
11403                !crate::VectorCore.member_has_permission(&cid_hex, &stranger, p).unwrap(),
11404                "an npub the roster never granted anything holds nothing"
11405            );
11406            assert!(
11407                crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, p).unwrap(),
11408                "the owner is supreme without a grant to cite"
11409            );
11410        }
11411
11412        // A banned npub holds nothing, whatever a stale grant still says.
11413        crate::db::community::set_community_banlist(&cid_hex, &[community.owner().unwrap().to_hex()], 1).unwrap();
11414        let banned_owner_is_still_owner =
11415            crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, Permissions::BAN).unwrap();
11416        assert!(banned_owner_is_still_owner, "the owner cannot be locked out of their own community");
11417
11418        // And a malformed npub is an error, never a silent grant.
11419        assert!(crate::VectorCore.member_has_permission(&cid_hex, "not-an-npub", Permissions::BAN).is_err());
11420    }
11421
11422    #[tokio::test]
11423    async fn a_refound_keeps_a_quiet_member_the_network_fold_forgot() {
11424        // The network fold only sees the current guestbook + the recent posting window,
11425        // so a quiet member whose Join lives on a buried epoch vanishes from it — and a
11426        // rotation that trusts the fold alone silently sheds them. Being forgotten must
11427        // not be a removal: only the explicit `removed` list is.
11428        let (_tmp, _guard, _owner) = init_test_db();
11429        let relay = MemoryRelay::new();
11430        let community = create_community(&relay, "QuietOnes", vec!["wss://r".into()], None).await.unwrap();
11431        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11432
11433        // A member the LOCAL store remembers (an old synced Join) but the WIRE has no
11434        // trace of: never posted, Join never published on the current plane.
11435        let quiet = Keys::generate().public_key();
11436        let ev = guestbook::GuestbookEvent { epoch: None, observed_at: None,
11437            rumor_id: [7u8; 32],
11438            entry: guestbook::GuestbookEntry::Join { member: quiet, invited_by: None, at_ms: 1_000 },
11439        };
11440        crate::db::community::set_guestbook(&cid_hex, std::slice::from_ref(&ev), 1).unwrap();
11441        assert!(stored_memberlist(&community).unwrap().contains(&quiet), "locally known");
11442        assert!(!memberlist(&relay, &community).await.unwrap().contains(&quiet), "network fold has forgotten them");
11443
11444        let rotated = refound_community(&relay, &community, &[]).await.unwrap();
11445
11446        // They made the new epoch's snapshot — the wire now carries them, so an outside
11447        // reader counts them and their client has a rekey blob to walk forward on.
11448        let wire = wire_guestbook_members(&relay, &rotated).await.unwrap();
11449        assert!(wire.contains(&quiet), "the quiet member survived the rotation onto the wire");
11450
11451        // The explicit removed list still removes, union or no union.
11452        let rotated2 = refound_community(&relay, &rotated, &[quiet]).await.unwrap();
11453        let wire2 = wire_guestbook_members(&relay, &rotated2).await.unwrap();
11454        assert!(!wire2.contains(&quiet), "an explicit removal is honoured");
11455    }
11456
11457    #[tokio::test]
11458    async fn revoking_a_husk_prunes_the_local_row_instead_of_erroring_forever() {
11459        // The list is per-creator and cross-device: another device (or Armada) can
11460        // retire a link this one still holds a row for. That row carries no signer_sk,
11461        // so no revocation can ever be built from it — the old behaviour errored with
11462        // "no such link in the invite list" BEFORE the local delete, leaving an entry
11463        // the owner could neither use nor remove.
11464        let (_tmp, _guard, _owner) = init_test_db();
11465        let relay = MemoryRelay::new();
11466        let community = create_community(&relay, "Husks", vec!["wss://r".into()], None).await.unwrap();
11467        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11468
11469        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
11470        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
11471        assert_eq!(crate::db::community::list_public_invites(&cid_hex).unwrap().len(), 1);
11472
11473        // "Another device" retires the link: republish the invite list without it.
11474        let signer = crate::signer::active_signer().unwrap();
11475        let my_pk = me_pk().unwrap();
11476        let mut list = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
11477        list.entries.retain(|e| e.token != token_hex);
11478        let ev = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.unwrap();
11479        relay.publish(&ev, &community.relays).await.unwrap();
11480
11481        // This device's revoke now completes by pruning, not by erroring.
11482        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
11483        assert!(
11484            crate::db::community::list_public_invites(&cid_hex).unwrap().is_empty(),
11485            "the husk row is gone — the panel can no longer show a link nobody can use or remove"
11486        );
11487
11488        // An unreadable list is NOT proof of absence: only a fetched list may prune.
11489        // (Guarded by the ok_or above the husk branch; a fresh row + an empty relay
11490        // would hit "no invite list found", never the prune.)
11491    }
11492
11493    #[tokio::test]
11494    async fn revoking_the_last_link_privatizes_and_rotates_but_an_earlier_revoke_does_not() {
11495        // CORD-06 §3: converting a Public Community to Private is a Refounding
11496        // trigger — revoking stops NEW acquisitions, only the rotation cuts off
11497        // everyone who already fetched the bundle. v1 does this; v2 shipped
11498        // without it. Revoking a link while ANOTHER stays live is not the
11499        // conversion, and must not burn an epoch.
11500        let (_tmp, _guard, _owner) = init_test_db();
11501        let relay = MemoryRelay::new();
11502        let community = create_community(&relay, "Privatize", vec!["wss://r".into()], None).await.unwrap();
11503        assert_eq!(community.root_epoch, Epoch(0));
11504
11505        let a = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
11506        let b = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
11507        assert!(community_is_public(&relay, &community).await, "two live links = Public");
11508
11509        // Revoking ONE of two: still Public, still epoch 0 — no rotation.
11510        revoke_public_link(&relay, &community, &crate::simd::hex::bytes_to_hex_16(&a.token)).await.unwrap();
11511        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11512        assert_eq!(held.root_epoch, Epoch(0), "a link remains live — not the Public→Private conversion");
11513        assert!(community_is_public(&relay, &held).await, "still Public");
11514
11515        // Revoking the LAST one privatizes AND rotates.
11516        revoke_public_link(&relay, &held, &crate::simd::hex::bytes_to_hex_16(&b.token)).await.unwrap();
11517        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11518        assert!(!community_is_public(&relay, &held).await, "no live links = Private");
11519        assert_eq!(held.root_epoch, Epoch(1), "privatizing rotated the base key");
11520        // The rotation is a real one: the new epoch carries the split pair and
11521        // the old root is retired, so a link-joined lurker's key is now dead.
11522        assert!(held.control_pk.is_some(), "the privatize rotation mints the split like any base rotation");
11523        assert_ne!(held.community_root, community.community_root, "the base key actually rolled");
11524    }
11525
11526    #[tokio::test]
11527    async fn a_legacy_community_upgrades_as_a_side_effect_of_its_next_refounding() {
11528        // CORD-06 §3: a compliant Rotator performing ANY base rotation MUST mint
11529        // the split — a pre-split community upgrades with nobody deciding to.
11530        let (_tmp, _guard, owner) = init_test_db();
11531        let relay = MemoryRelay::new();
11532        let community = create_legacy_community(&relay, &owner, "Legacy", vec!["wss://r".into()]).await;
11533        assert!(community.control_pk.is_none(), "starts pre-split");
11534
11535        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
11536        assert_eq!(refounded.root_epoch, Epoch(1));
11537        assert!(refounded.control_pk.is_some() && refounded.control_root.is_some(), "the rotation minted the split");
11538        // And the persisted row agrees (the pair survives a reload).
11539        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11540        assert_eq!(reloaded.control_pk, refounded.control_pk);
11541        assert_eq!(reloaded.control_root, refounded.control_root);
11542    }
11543
11544    #[tokio::test]
11545    async fn an_old_build_deletion_recovers_from_the_stale_bundle_across_the_split() {
11546        // The 0.4.0/0.4.1 upgrade path: those builds hit the widened base blob,
11547        // conclude a false removal, and DELETE the community locally. Recovery on
11548        // upgrade is the Community-List resurrection: re-accept the STALE-epoch
11549        // material (the old plane is still served), then walk the rekey chain
11550        // forward — now parsing the 104-byte form — to the current epoch.
11551        let (bed, owner, member) = TestBed::new();
11552        bed.swap_to(&owner);
11553        let community = create_legacy_community(&bed.relay, &owner.keys, "Recover", bed.relays.clone()).await;
11554        let stale_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11555
11556        // The member joins at the legacy epoch 0.
11557        bed.swap_to(&member);
11558        let joined = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
11559        assert!(joined.control_pk.is_none());
11560
11561        // The owner bans nobody but rotates (any refound upgrades to the split).
11562        bed.swap_to(&owner);
11563        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
11564
11565        // The member's OLD build falsely self-removes and deletes the community.
11566        bed.swap_to(&member);
11567        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11568        crate::db::community::delete_community(&cid_hex).unwrap();
11569        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
11570
11571        // Upgrade-recovery: re-accept the stale epoch-0 material (what
11572        // sync_community_list does from the surviving 13302 entry)...
11573        let readopted = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
11574        assert_eq!(readopted.root_epoch, Epoch(0), "re-anchored at the stale epoch");
11575        // ...then the walk crosses the split rotation and adopts the pair.
11576        let follow = follow_rekeys(&bed.relay, &readopted, &crate::db::current_session()).await.unwrap();
11577        assert!(!follow.self_removed, "never a false removal on this build");
11578        let healed = follow.updated.expect("the walk adopts the split rotation");
11579        assert_eq!(healed.root_epoch, Epoch(1));
11580        assert_eq!(healed.control_pk, refounded.control_pk, "the member reads the upgraded plane");
11581        assert_eq!(healed.control_root, None, "a plain member never receives the secret");
11582        // And the upgraded control plane folds for them (the compacted state).
11583        let folded = follow_control(&bed.relay, &healed).await;
11584        assert!(folded.is_ok(), "the recovered member folds the split plane: {folded:?}");
11585    }
11586
11587    #[tokio::test]
11588    async fn an_unopenable_blob_at_my_locator_never_concludes_removal() {
11589        // CORD-06 §2: removal = NO blob for my locator across a complete
11590        // rotation. A blob that EXISTS but won't open (a future width, a rotator
11591        // bug) must park — turning it into a self-removal is exactly how the
11592        // pre-split builds deleted communities on the first widened rotation.
11593        let (bed, owner, member) = TestBed::new();
11594        bed.swap_to(&owner);
11595        let community = create_community(&bed.relay, "Garbled", bed.relays.clone(), None).await.unwrap();
11596        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11597        bed.swap_to(&member);
11598        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11599
11600        // The owner mints a complete, authorized rotation whose blob AT THE
11601        // MEMBER'S LOCATOR is garbage (undecryptable), owner's own blob real.
11602        bed.swap_to(&owner);
11603        let new_epoch = Epoch(1);
11604        let new_root = [0x4Du8; 32];
11605        let control_root = [0x4Eu8; 32];
11606        let control_pk = super::super::derive::control_signer_group_key(&control_root, community.id(), new_epoch).pk();
11607        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11608        let garbled = rekey::RekeyBlob {
11609            locator: rekey::blob_locator(&owner.keys.public_key().to_bytes(), &member.keys.public_key().to_bytes(), RekeyScope::Root, new_epoch),
11610            wrapped: "bm90LWEtcmVhbC1uaXA0NC1wYXlsb2Fk".into(),
11611        };
11612        let own = rekey::build_base_blob(
11613            &crate::signer::ActiveSigner::Keys(owner.keys.clone()),
11614            &owner.keys.public_key().to_bytes(),
11615            &owner.keys.public_key(),
11616            new_epoch,
11617            &new_root,
11618            &control_pk.to_bytes(),
11619            Some(&control_root),
11620        )
11621        .await
11622        .unwrap();
11623        let base_group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11624        let chunks = rekey::build_rekey_chunks_local(&owner.keys, &base_group, RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[garbled, own], 2_000, None, false).unwrap();
11625        for c in &chunks {
11626            bed.relay.publish(c, &community.relays).await.unwrap();
11627        }
11628
11629        // The member's follow PARKS: no adoption, and NEVER a removal.
11630        bed.swap_to(&member);
11631        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
11632        assert!(!follow.self_removed, "an unopenable blob at my locator is not an exclusion");
11633        assert!(follow.updated.is_none(), "nothing adopted from an unreadable blob");
11634        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "the community survives");
11635        // …and the park is LEGIBLE. A silent park is indistinguishable from "no
11636        // rotation happened", which is what makes an undecryptable blob planted
11637        // at a protected member's locator an abusable severing tool.
11638        assert!(follow.wedged, "an unopenable key delivery must be surfaced, not swallowed");
11639    }
11640
11641    #[tokio::test]
11642    async fn a_control_wrap_is_refused_on_a_stale_epoch_or_a_non_deriving_secret() {
11643        // What the "no rank-gating on adoption" argument rests on (CORD-04 §3):
11644        // a wrap is adopted only if its secret derives to the control_pk held
11645        // for the epoch named INSIDE its ciphertext. That derive check is the
11646        // load-bearing gate (revert-verified); the explicit epoch comparison
11647        // beside it is defense in depth and can't be isolated, since deriving
11648        // at a stale epoch already misses the held address. Compaction re-wraps
11649        // a Grant head verbatim across Refoundings, so stale wraps are a
11650        // structural certainty, not an edge case.
11651        let (bed, owner, member) = TestBed::new();
11652        bed.swap_to(&owner);
11653        let community = create_community(&bed.relay, "AdoptGate", bed.relays.clone(), None).await.unwrap();
11654        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11655        bed.swap_to(&member);
11656        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11657        assert!(joined.control_root.is_none());
11658
11659        // Build a Grant edition carrying `control_wrap` with a chosen plaintext.
11660        let wrap_for = |epoch: Epoch, secret: &[u8; 32]| {
11661            let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(owner.keys.secret_key(), &member.keys.public_key()).unwrap();
11662            let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(epoch, secret).as_bytes()).unwrap();
11663            base64_simd::STANDARD.encode_to_string(&ct)
11664        };
11665        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.keys.public_key().to_bytes());
11666        let ed_with = |wrap: String| {
11667            let grant = MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![] };
11668            let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, Some(wrap)).unwrap();
11669            let rumor = control::build_edition_rumor(owner.keys.public_key(), vsk::GRANT, &eid, 1, None, &content, 1_000, None);
11670            control::parse_edition_rumor(&rumor).unwrap()
11671        };
11672
11673        // (a) A wrap for a DIFFERENT epoch — the real secret, wrong epoch.
11674        let real = community.control_root.unwrap();
11675        let stale = ed_with(wrap_for(Epoch(joined.root_epoch.0 + 1), &real));
11676        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&stale)).await, None, "a stale-epoch wrap is refused");
11677
11678        // (b) Right epoch, a secret that does NOT derive to the held pk.
11679        let forged = ed_with(wrap_for(joined.root_epoch, &[0x66u8; 32]));
11680        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&forged)).await, None, "a non-deriving secret is refused");
11681
11682        // (c) The real secret at the held epoch IS adopted — the gates above
11683        // reject on their own merits, not because adoption is broken.
11684        let good = ed_with(wrap_for(joined.root_epoch, &real));
11685        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&good)).await, Some(real), "the true wrap adopts");
11686    }
11687
11688    #[test]
11689    fn the_vault_carries_the_split_and_the_twin_strips_the_secret() {
11690        // CORD-02 §8: the list carries every key its holder has — the address
11691        // for all, the secret for staff. The migration `m` (member-bound) MUST
11692        // strip the secret.
11693        let owner = Keys::generate();
11694        let g = control::genesis(&owner, control::CommunityMetadata { name: "Vault".into(), ..Default::default() }, 1_000).unwrap();
11695        let c = CommunityV2::from_genesis(&g, "Vault", None, vec!["wss://r".into()], 0);
11696        let jm = join_material(&c);
11697        assert_eq!(jm.control_pk, c.control_pk.map(|p| p.to_hex()));
11698        assert_eq!(jm.control_root, Some(crate::simd::hex::bytes_to_hex_32(&g.control_root)));
11699        let twin = twin_join_material(&c);
11700        assert_eq!(twin.control_pk, jm.control_pk, "members get the address");
11701        assert_eq!(twin.control_root, None, "members never get the secret");
11702        let bundle = material_to_invite(&jm);
11703        assert_eq!(bundle.control_pk, jm.control_pk, "a rehydrating bundle carries the address");
11704    }
11705
11706    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
11707    // any control edition) must not be able to SUPPRESS a role or grant by forging a
11708    // higher version at its coordinate. Owner-only signers mask this entirely, so every
11709    // attacker below signs as a NON-owner member.
11710
11711    #[tokio::test]
11712    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
11713        let (bed, owner, attacker) = TestBed::new();
11714        bed.swap_to(&owner);
11715        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
11716        let victim = Keys::generate().public_key();
11717        grant_admin(&bed.relay, &community, &victim).await.unwrap();
11718
11719        // The admin role sits at a deterministic, publicly-computable coordinate.
11720        let admin_rid = fetch_authority(&bed.relay, &community)
11721            .await
11722            .roles
11723            .roles
11724            .iter()
11725            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
11726            .unwrap()
11727            .role_id
11728            .clone();
11729        // Attacker forges v2 of that exact role, stripping its powers.
11730        publish_role(
11731            &bed.relay,
11732            &community,
11733            &attacker.keys,
11734            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
11735            2,
11736        )
11737        .await;
11738
11739        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
11740        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
11741        assert!(
11742            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
11743            "the floor advances only to the AUTHORIZED head (owner v1)"
11744        );
11745        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
11746    }
11747
11748    #[tokio::test]
11749    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
11750        let (bed, owner, attacker) = TestBed::new();
11751        bed.swap_to(&owner);
11752        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
11753        let victim = Keys::generate();
11754        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
11755
11756        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
11757        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
11758
11759        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
11760        assert!(
11761            authority.roles.is_admin(&victim.public_key().to_hex()),
11762            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
11763        );
11764    }
11765
11766    #[tokio::test]
11767    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
11768        let (bed, owner, attacker) = TestBed::new();
11769        bed.swap_to(&owner);
11770        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
11771        let victim = Keys::generate().public_key();
11772        grant_admin(&bed.relay, &community, &victim).await.unwrap();
11773
11774        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
11775        // unauthorized, so the post-authorize cap never sees them.
11776        for i in 0u8..6 {
11777            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
11778            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11779        }
11780
11781        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
11782        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
11783        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
11784    }
11785
11786    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
11787    /// roster + banlist — two clients converge iff these match.
11788    fn authority_fingerprint(a: &AuthoritySet) -> String {
11789        let mut roles = a.roles.roles.clone();
11790        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
11791        let mut grants = a.roles.grants.clone();
11792        for g in &mut grants {
11793            g.role_ids.sort();
11794        }
11795        grants.sort_by(|x, y| x.member.cmp(&y.member));
11796        let banned: Vec<&String> = a.banned.iter().collect();
11797        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
11798    }
11799
11800    #[tokio::test]
11801    async fn the_v2_authority_fold_is_order_independent() {
11802        // THE core consensus property: two honest clients that receive the SAME
11803        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
11804        // authorized roster + banlist (author-aware select_authorized + banlist
11805        // fold + cap, all deterministic). A divergence here would fork the
11806        // community's moderation state between honest members.
11807        let (bed, owner, _a) = TestBed::new();
11808        bed.swap_to(&owner);
11809        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
11810
11811        // A rich control plane: two admins, an extra role, two grants (one of them a
11812        // grant to a member the owner then bans), a banlist, a rename, a channel.
11813        let admin1 = Keys::generate().public_key();
11814        let admin2 = Keys::generate().public_key();
11815        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
11816        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
11817        let mod_rid = "5c".repeat(32);
11818        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
11819        let member = Keys::generate().public_key();
11820        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
11821        let banned_member = Keys::generate().public_key();
11822        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
11823        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
11824        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
11825        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
11826        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
11827
11828        let editions = fetch_control(&bed.relay, &community).await;
11829        let floors = load_floors(&community);
11830        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
11831
11832        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
11833
11834        // Fold under many arrival permutations: reversed, and several deterministic
11835        // rotations/interleavings. Every one must match the baseline.
11836        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
11837        let mut rev = editions.clone();
11838        rev.reverse();
11839        orders.push(rev);
11840        for shift in [1usize, 3, 5, 7] {
11841            let n = editions.len();
11842            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
11843        }
11844        // A deterministic "shuffle": interleave from both ends.
11845        let mut zip = Vec::with_capacity(editions.len());
11846        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
11847        while lo <= hi {
11848            zip.push(editions[lo as usize].clone());
11849            if lo != hi {
11850                zip.push(editions[hi as usize].clone());
11851            }
11852            lo += 1;
11853            hi -= 1;
11854        }
11855        orders.push(zip);
11856
11857        for (i, order) in orders.iter().enumerate() {
11858            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
11859            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
11860        }
11861        // Sanity: the fingerprint reflects real state (the banned member is out, the
11862        // honest admins are in).
11863        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
11864        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
11865    }
11866
11867    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
11868    struct FetchErrors(MemoryRelay);
11869    #[async_trait::async_trait]
11870    impl crate::community::transport::Transport for FetchErrors {
11871        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
11872        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
11873            self.0.publish(e, r).await
11874        }
11875        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11876            Err("relay down".to_string())
11877        }
11878        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
11879            self.0.publish_durable(e, r).await
11880        }
11881    }
11882
11883    #[tokio::test]
11884    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
11885        let (bed, owner, victim) = TestBed::new();
11886        bed.swap_to(&owner);
11887        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
11888        let victim_hex = victim.keys.public_key().to_hex();
11889        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
11890        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11891        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
11892
11893        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
11894        // return an empty banlist (which would silently un-ban on withheld data).
11895        let down = FetchErrors(MemoryRelay::new());
11896        let view = fetch_authority(&down, &community).await;
11897        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
11898    }
11899
11900    #[tokio::test]
11901    async fn the_fold_pages_past_a_full_window_to_find_the_roster_it_tracks() {
11902        // Soapbox's actual shape. The grants that define the admin list are OLDER than
11903        // one page of a busy control plane. An entity absent from the window dangles no
11904        // link, so it raises no gap, so the pager called the very first page "coherent"
11905        // and stopped — every fold offered zero grants, forever, and only the
11906        // completeness guard retaining the cache kept the admin list alive. That made
11907        // the cache the last surviving copy of the roster rather than a cache.
11908        //
11909        // Coherent is not complete. The fold must page until what it tracks is found.
11910        let (bed, owner, _m) = TestBed::new();
11911        bed.swap_to(&owner);
11912        let community = create_community(&bed.relay, "DeepFold", bed.relays.clone(), None).await.unwrap();
11913        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11914        let rid = "e7".repeat(32);
11915        let admin = Keys::generate();
11916
11917        // The roster, at t=1_000 — the OLDEST thing on the plane.
11918        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
11919        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
11920        follow_control(&bed.relay, &community).await.unwrap();
11921        assert!(
11922            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
11923            "baseline: the admin folds while its editions are still in reach",
11924        );
11925
11926        // Bury it under MORE than one page of strictly NEWER control traffic.
11927        for i in 0..(FOLLOW_PAGE + 50) {
11928            publish_channel_edition_at(
11929                &bed.relay, &community, &owner.keys,
11930                &ChannelId(crate::simd::hex::hex_to_bytes_32(&format!("{:064x}", i + 1))),
11931                &format!("ch{i}"), 1, 2_000 + i as u64,
11932            ).await;
11933        }
11934
11935        // Forget the grant's FLOOR. Re-establishing it is the observable proof that
11936        // the fold actually reached the grant edition, rather than merely retaining a
11937        // cached roster it could not re-derive.
11938        let grant_eid = crate::simd::hex::bytes_to_hex_32(
11939            &crate::community::v2::derive::grant_locator(community.id(), &admin.public_key().to_bytes()),
11940        );
11941        crate::db::community::delete_edition_head(&cid_hex, &grant_eid).unwrap();
11942        assert!(
11943            crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_none(),
11944            "precondition: the floor is gone",
11945        );
11946
11947        follow_control(&bed.relay, &community).await.unwrap();
11948
11949        assert!(
11950            crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_some(),
11951            "the fold MUST page past the newest window and reach the grant that defines the \
11952             admin list — stopping at the first coherent-looking page is how a community \
11953             becomes unable to re-derive its own roster",
11954        );
11955        assert!(
11956            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
11957            "and the admin survives",
11958        );
11959    }
11960
11961    #[tokio::test]
11962    async fn a_grant_naming_a_role_its_own_holder_created_still_folds() {
11963        // The deadlock that emptied two live communities' rosters.
11964        //
11965        // A community grows the ordinary way: the owner promotes an admin, the admin
11966        // creates a role of their own, and the owner then grants that role onward. Now
11967        // the grant references a role whose AUTHOR draws authority from a grant — and
11968        // under all-or-nothing resolution neither can go first. The fixpoint lands
11969        // empty, and every grant in the community dies with it, the owner's included.
11970        //
11971        // Resolving the roles a grant CAN resolve breaks the cycle without granting
11972        // anything the author did not write: the retained roles are still rank-checked,
11973        // and the rest fold in on a later iteration.
11974        let (bed, owner, _m) = TestBed::new();
11975        bed.swap_to(&owner);
11976        let community = create_community(&bed.relay, "GrantCycle", bed.relays.clone(), None).await.unwrap();
11977        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11978        let admin = Keys::generate();
11979        let member = Keys::generate();
11980
11981        // 1. The owner's own role, and a grant of it to the admin.
11982        let owner_role = "aa".repeat(32);
11983        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&owner_role, Permissions::ADMIN_ALL), 1).await;
11984        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![owner_role.clone()], 1).await;
11985        follow_control(&bed.relay, &community).await.unwrap();
11986        assert!(
11987            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
11988            "the admin is promoted",
11989        );
11990
11991        // 2. The ADMIN creates a role of their own, beneath theirs.
11992        let admin_made = "bb".repeat(32);
11993        let mut made = admin_role(&admin_made, Permissions::BAN);
11994        made.position = 5;
11995        publish_role(&bed.relay, &community, &admin, &made, 1).await;
11996
11997        follow_control(&bed.relay, &community).await.unwrap();
11998        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11999        assert!(
12000            roster.role(&admin_made).is_some(),
12001            "an admin-authored role folds while its author's grant is resolvable",
12002        );
12003        assert!(
12004            roster.is_admin(&admin.public_key().to_hex()),
12005            "and the admin keeps their own standing — a cycle here empties the roster",
12006        );
12007
12008        // 3. The owner grants the ADMIN-MADE role to a member, alongside a role that
12009        //    is not resolvable at all. Under all-or-nothing the whole grant dies and
12010        //    takes the roster with it; resolved partially, the member gets what the
12011        //    owner actually granted.
12012        publish_grant(
12013            &bed.relay, &community, &owner.keys, &member.public_key(),
12014            vec![admin_made.clone(), "cc".repeat(32)], 1,
12015        ).await;
12016        follow_control(&bed.relay, &community).await.unwrap();
12017        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12018        let mg = roster.grants.iter().find(|g| g.member == member.public_key().to_hex());
12019        assert!(
12020            mg.is_some_and(|g| g.role_ids.contains(&admin_made)),
12021            "the resolvable half of the grant is honored",
12022        );
12023        assert!(
12024            mg.is_some_and(|g| !g.role_ids.iter().any(|r| r == &"cc".repeat(32))),
12025            "and the unresolvable half is NOT — partial resolution only ever grants less",
12026        );
12027        assert!(
12028            roster.is_admin(&admin.public_key().to_hex()),
12029            "the rest of the roster survives it",
12030        );
12031    }
12032
12033    #[tokio::test]
12034    async fn a_cold_device_pages_past_the_first_window_to_find_an_older_grant() {
12035        // The early break stops paging once every entity we track has a head. On a
12036        // COLD device we track NOTHING, so that condition is vacuously true and the
12037        // walk stops at page one — the roster is then whatever the newest window
12038        // happened to hold, and an admin granted before it silently is not one.
12039        // That is the roster collapse, arrived at by an optimisation.
12040        let (bed, owner, _m) = TestBed::new();
12041        bed.swap_to(&owner);
12042        let community = create_community(&bed.relay, "ColdPage", bed.relays.clone(), None).await.unwrap();
12043        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12044
12045        // The grant that matters goes down FIRST, so a full newest-first page buries it.
12046        let admin = Keys::generate();
12047        let rid = "e5".repeat(32);
12048        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12049        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12050
12051        // Then bury it under a full page of newer editions.
12052        for v in 2..(FOLLOW_PAGE as u64 + 2) {
12053            publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000 + v).await;
12054        }
12055
12056        follow_control(&bed.relay, &community).await.unwrap();
12057
12058        assert!(
12059            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12060            "a cold device must page PAST the first window — stopping there caches a \
12061             partial authority as its own baseline and drops every older grant",
12062        );
12063    }
12064
12065    #[tokio::test]
12066    async fn a_truncated_cold_fold_banks_nothing_at_all() {
12067        // A page that is entirely one second wide is a wall `until` cannot step past,
12068        // so the plane below it is unreachable and the fold is TRUNCATED. Refusing to
12069        // show that partial roster is not enough — it must not be BANKED either. A
12070        // stored baseline carries a provenance stamp that the next, complete fold has
12071        // to beat, and names entities that a complete fold may not head, so banking a
12072        // partial window is how a client talks itself into a permanent wedge.
12073        let (bed, owner, _m) = TestBed::new();
12074        bed.swap_to(&owner);
12075        let community = create_community(&bed.relay, "Wall", bed.relays.clone(), None).await.unwrap();
12076        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12077
12078        let admin = Keys::generate();
12079        let rid = "e5".repeat(32);
12080        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12081        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12082        // A full page of editions sharing ONE second, burying the grant behind the wall.
12083        for v in 2..(FOLLOW_PAGE as u64 + 2) {
12084            publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000).await;
12085        }
12086
12087        follow_control(&bed.relay, &community).await.unwrap();
12088
12089        assert_eq!(
12090            crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12091            0,
12092            "a truncated fold must bank NO baseline — a stored partial roster outranks \
12093             the complete fold that follows it and can never be replaced",
12094        );
12095    }
12096
12097    #[tokio::test]
12098    async fn an_older_window_never_overwrites_a_newer_cached_roster() {
12099        // The roster cache carries the provenance timestamp of the fold that wrote it.
12100        // A later fold reading an OLDER window (a lagging relay, a short page) must not
12101        // replace it — that is a downgrade with no gap and no truncation to catch it.
12102        let (bed, owner, _m) = TestBed::new();
12103        bed.swap_to(&owner);
12104        let community = create_community(&bed.relay, "Monotone", bed.relays.clone(), None).await.unwrap();
12105        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12106
12107        let admin = Keys::generate();
12108        let rid = "e5".repeat(32);
12109        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12110        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12111        follow_control(&bed.relay, &community).await.unwrap();
12112        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12113
12114        // Stamp the cached roster far in the future: any real fold now reads older.
12115        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12116        crate::db::community::set_community_roles(&cid_hex, &roster, i64::MAX).unwrap();
12117
12118        follow_control(&bed.relay, &community).await.unwrap();
12119        assert!(
12120            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12121            "an older window must not overwrite a newer cached roster",
12122        );
12123        assert_eq!(
12124            crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12125            i64::MAX,
12126            "and the newer provenance stamp survives — otherwise the next fold downgrades freely",
12127        );
12128    }
12129
12130    /// A fast majority that does not hold the older editions. Quorum returns as soon
12131    /// as it answers, so those editions read as absent; only a Full read waits for the
12132    /// relay that has them. This is the shape a slow Ditto/Soapbox relay produces.
12133    struct SlowMajority<'a>(&'a MemoryRelay, u64);
12134    #[async_trait::async_trait]
12135    impl Transport for SlowMajority<'_> {
12136        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12137            self.0.publish(e, r).await
12138        }
12139        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
12140            let out = self.0.fetch(query, relays).await?;
12141            if query.evidence == crate::community::transport::Evidence::Full {
12142                return Ok(out);
12143            }
12144            Ok(out.into_iter().filter(|e| e.created_at.as_secs() > self.1).collect())
12145        }
12146        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
12147            self.0.fetch_plane(plane, query, relays).await
12148        }
12149        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12150            self.0.publish_durable(e, r).await
12151        }
12152    }
12153
12154    #[tokio::test]
12155    async fn a_live_link_a_quorum_cannot_see_still_reads_public() {
12156        // The ban path asks this to decide whether to refound. CORD-05 §5 forbids
12157        // refounding a Public community — the link refresh re-posts the bundle behind
12158        // the same URL, so the rotation severs nothing and strands foreign-link joiners
12159        // on a buried epoch. A Quorum read that misses another creator's Registry makes
12160        // a Public community read Private, which takes exactly that forbidden branch.
12161        let (_tmp, _guard, _owner) = init_test_db();
12162        let relay = MemoryRelay::new();
12163        let community = create_community(&relay, "QuorumBlind", vec!["wss://r".into()], None).await.unwrap();
12164        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12165        assert!(community_is_public(&relay, &community).await, "the link is live on the plane");
12166
12167        // u64::MAX: nothing is newer, so the Quorum read is blinded entirely. The
12168        // link is minted with the real clock, so a small cutoff filters nothing.
12169        let slow = SlowMajority(&relay, u64::MAX);
12170        assert!(
12171            community_is_public(&slow, &community).await,
12172            "a live link only a minority relay serves must still read Public — reading \
12173             Private here refounds a Public community on ban, severing nothing",
12174        );
12175    }
12176
12177    #[tokio::test]
12178    async fn a_quorum_that_cannot_see_a_stored_entity_escalates_to_full() {
12179        // The roster fold draws an absence verdict, and an absence verdict from a
12180        // MAJORITY is not one: the same fast subset answers every boot, so the retain
12181        // never lifts and the cache freezes holding the only surviving copy of the
12182        // roster. Read again at Full before concluding anything is gone.
12183        let (bed, owner, _m) = TestBed::new();
12184        bed.swap_to(&owner);
12185        let community = create_community(&bed.relay, "Escalate", bed.relays.clone(), None).await.unwrap();
12186        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12187
12188        let admin = Keys::generate();
12189        let rid = "e5".repeat(32);
12190        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12191        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12192
12193        // Bank a complete roster the honest way, so the entities are STORED and floored.
12194        follow_control(&bed.relay, &community).await.unwrap();
12195        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12196
12197        // A SECOND admin is granted. Its edition lands on the plane like every other,
12198        // below the cutoff — so a Quorum read cannot see it, and a client that concludes
12199        // from Quorum alone retains the old roster and never learns about them.
12200        let admin2 = Keys::generate();
12201        publish_grant(&bed.relay, &community, &owner.keys, &admin2.public_key(), vec![rid], 1).await;
12202
12203        let slow = SlowMajority(&bed.relay, 5_000);
12204        follow_control(&slow, &community).await.unwrap();
12205
12206        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12207        assert!(
12208            roster.is_admin(&admin.public_key().to_hex()),
12209            "the first admin survives — the retain is doing its job",
12210        );
12211        assert!(
12212            roster.is_admin(&admin2.public_key().to_hex()),
12213            "and the roster still ADVANCES: a Quorum read that could not account for a \
12214             stored entity must re-read at Full, or the cache freezes holding the only \
12215             surviving copy of the roster and no promotion ever lands again",
12216        );
12217    }
12218
12219    #[tokio::test]
12220    async fn one_gapped_entity_does_not_freeze_the_rest_of_the_roster() {
12221        // `authority.gapped` is a global OR across every entity, but the spec makes a
12222        // gap fail closed FOR THAT ENTITY — suspend it, refetch it — not for the whole
12223        // community. Treating it as a veto froze live rosters that were otherwise
12224        // folding perfectly, and permanently once the plane is exhausted and no further
12225        // paging can resolve the hole.
12226        let (bed, owner, _m) = TestBed::new();
12227        bed.swap_to(&owner);
12228        let community = create_community(&bed.relay, "OneGap", bed.relays.clone(), None).await.unwrap();
12229        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12230
12231        // A healthy admin the plane fully describes.
12232        let admin = Keys::generate();
12233        let rid = "e5".repeat(32);
12234        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12235        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12236
12237        // And a second role the plane DOES serve, floored at a hash nothing chains to:
12238        // its editions are present but unanchored, which is the withholding shape that
12239        // fails closed and raises the gap. A floor on an entity absent from the window
12240        // would not — the fold only visits entities it sees.
12241        let rid2 = "c7".repeat(32);
12242        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid2, Permissions::BAN), 5).await;
12243        crate::db::community::set_edition_head_at_epoch(&cid_hex, &rid2, 3, &[9u8; 32], &[9u8; 32], community.root_epoch.0).unwrap();
12244
12245        follow_control(&bed.relay, &community).await.unwrap();
12246
12247        assert!(
12248            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12249            "a hole in ONE entity must not withhold every other entity's authority — that is \
12250             how a community that folds correctly still shows an empty roster",
12251        );
12252    }
12253
12254    #[tokio::test]
12255    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
12256        let (bed, owner, _m) = TestBed::new();
12257        bed.swap_to(&owner);
12258        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
12259        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12260        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
12261        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
12262
12263        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
12264        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12265        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12266        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
12267        follow_control(&bed.relay, &community).await.unwrap().updated;
12268        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
12269
12270        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
12271        // drops both admins yet raises no gap. The completeness gate must RETAIN the
12272        // stored roster rather than persist the lossy one.
12273        let relay2 = MemoryRelay::new();
12274        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12275        follow_control(&relay2, &community).await.unwrap().updated;
12276        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12277        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
12278    }
12279
12280    #[tokio::test]
12281    async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
12282        // CORD-04 §5 covers EVERY control entity, not just the delegation chain.
12283        // Vector already gated roles and grants in-fold; metadata, channels and
12284        // the banlist resolved on permission alone, so a client one sweep behind
12285        // honored an edit from an admin whose demotion it had not read yet.
12286        let (_tmp, _guard, owner) = init_test_db();
12287        let relay = MemoryRelay::new();
12288        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12289        let admin = Keys::generate();
12290        let rid = "a7".repeat(32);
12291        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
12292        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
12293
12294        // The admin acts WITHOUT citing (what every pre-citation client emitted).
12295        let group = control::ControlPlane::of(&community).write_group().unwrap();
12296        let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
12297        let rumor = control::build_edition_rumor(
12298            admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
12299            head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
12300            &serde_json::to_string(&meta).unwrap(), 1_000, None,
12301        );
12302        let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12303        relay.publish(&wrap, &community.relays).await.unwrap();
12304
12305        let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
12306        let victim = Keys::generate().public_key().to_hex();
12307        let ban_rumor = control::build_edition_rumor(
12308            admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
12309            &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
12310        );
12311        let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12312        relay.publish(&ban_wrap, &community.relays).await.unwrap();
12313
12314        let updated = follow_control(&relay, &community).await.unwrap().updated;
12315        assert!(
12316            updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
12317            "an uncited metadata edit must not be honored",
12318        );
12319        let authority = fetch_authority(&relay, &community).await;
12320        assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
12321        // The positive case (this same admin, citing, lands) is
12322        // `an_authorized_admin_edits_metadata_but_a_demoted_one_cannot` — its
12323        // helper cites, so it proves the gate is the CITATION and not the
12324        // permission. Re-proving it here would need a fresh chain anyway: a
12325        // cited edition chaining onto the rejected one above is gapped, not
12326        // refused.
12327    }
12328
12329    #[tokio::test]
12330    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
12331        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
12332        // owner revokes the grant, the (now unauthorized) admin's further edit drops
12333        // and the name holds at the last authorized state.
12334        let (_tmp, _guard, owner) = init_test_db();
12335        let relay = MemoryRelay::new();
12336        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12337        let admin = Keys::generate();
12338        let rid = "a1".repeat(32);
12339        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
12340        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
12341        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
12342
12343        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("admin edit authorized");
12344        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
12345
12346        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
12347        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
12348        let _ = follow_control(&relay, &community).await.unwrap().updated;
12349        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12350        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
12351    }
12352
12353    #[tokio::test]
12354    async fn a_roleless_member_cannot_edit_metadata() {
12355        let (_tmp, _guard, _owner) = init_test_db();
12356        let relay = MemoryRelay::new();
12357        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
12358        let stranger = Keys::generate();
12359        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
12360        assert!(
12361            follow_control(&relay, &community).await.unwrap().updated.is_none(),
12362            "a roleless member's metadata edit never folds"
12363        );
12364    }
12365
12366    #[tokio::test]
12367    async fn a_self_signed_grant_is_not_authority() {
12368        // The self-promotion defense: a member self-signs both a role and a grant of
12369        // it to themselves. authorize_delegation drops both (their signer never traces
12370        // to the owner), so their metadata edit stays unauthorized.
12371        let (_tmp, _guard, _owner) = init_test_db();
12372        let relay = MemoryRelay::new();
12373        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
12374        let rogue = Keys::generate();
12375        let rid = "b2".repeat(32);
12376        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12377        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
12378        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
12379        assert!(
12380            follow_control(&relay, &community).await.unwrap().updated.is_none(),
12381            "a self-signed grant confers no authority"
12382        );
12383    }
12384
12385    #[tokio::test]
12386    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
12387        let (_tmp, _guard, owner) = init_test_db();
12388        let relay = MemoryRelay::new();
12389        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
12390        let target = "cc".repeat(32);
12391
12392        // A non-BAN-holder's banlist edition is folded but NOT enforced.
12393        let rogue = Keys::generate();
12394        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
12395        let floors = load_floors(&community);
12396        let editions = fetch_control(&relay, &community).await;
12397        let authority = fold_authority(&community, &editions, &floors);
12398        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
12399
12400        // The owner (supreme, holds BAN) bans the target: now enforced.
12401        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
12402        let editions = fetch_control(&relay, &community).await;
12403        let authority = fold_authority(&community, &editions, &floors);
12404        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
12405    }
12406
12407    #[tokio::test]
12408    async fn a_banned_admin_loses_all_authority() {
12409        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
12410        // banned admin's authority is dropped and their edits refused.
12411        let (_tmp, _guard, owner) = init_test_db();
12412        let relay = MemoryRelay::new();
12413        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
12414        let admin = Keys::generate();
12415        let rid = "e5".repeat(32);
12416        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
12417        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
12418        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
12419        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
12420
12421        assert!(
12422            follow_control(&relay, &community).await.unwrap().updated.is_none(),
12423            "a banned admin's edit is dropped even with an unstripped grant"
12424        );
12425        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
12426        assert!(authority.banned.contains(&admin.public_key().to_hex()));
12427        assert!(
12428            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
12429            "a banned admin holds no bit"
12430        );
12431    }
12432
12433    #[tokio::test]
12434    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
12435        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
12436        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
12437        // and NOT the owner (supreme, unbannable).
12438        let (_tmp, _guard, owner) = init_test_db();
12439        let relay = MemoryRelay::new();
12440        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
12441        let admin = Keys::generate();
12442        let moder = Keys::generate();
12443        let stranger = Keys::generate();
12444        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
12445        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;
12446        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;
12447        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
12448        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
12449        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
12450
12451        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
12452        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
12453        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
12454        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
12455    }
12456
12457    #[tokio::test]
12458    async fn an_unauthorized_higher_banlist_cannot_unban() {
12459        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
12460        // must not erase the owner's ban (author-aware head selection + persisted
12461        // banlist retention).
12462        let (_tmp, _guard, owner) = init_test_db();
12463        let relay = MemoryRelay::new();
12464        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
12465        let target = "cc".repeat(32);
12466        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
12467        follow_control(&relay, &community).await.unwrap().updated; // persists the ban
12468
12469        let rogue = Keys::generate();
12470        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
12471        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
12472        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
12473    }
12474
12475    #[tokio::test]
12476    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
12477        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
12478        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
12479        let (_tmp, _guard, _owner) = init_test_db();
12480        let relay = MemoryRelay::new();
12481        let relays = vec!["wss://r".to_string()];
12482        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
12483        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
12484        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
12485
12486        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
12487        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
12488        assert_eq!(rehydrated[0].id().0, community.id().0);
12489        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
12490    }
12491
12492    #[tokio::test]
12493    async fn a_retried_leave_tombstone_cannot_bury_a_rejoin_made_meanwhile() {
12494        let (_tmp, _guard, _owner) = init_test_db();
12495        let relay = MemoryRelay::new();
12496        let relays = vec!["wss://r".to_string()];
12497        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
12498        let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12499
12500        // The leave stamps its own removal time, and its publish fails.
12501        let left_at = now_ms() - 1000;
12502        tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
12503        // The user rejoins before the retry gets through.
12504        republish_community_list(&relay, Some(community.id())).await.unwrap();
12505        assert!(fetch_fragments(&relay, &relays).await.unwrap().unwrap().list.is_live(&cid));
12506
12507        // The retry lands late, carrying the ORIGINAL stamp — which the rejoin outranks.
12508        tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
12509        let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
12510        assert!(set.list.is_live(&cid), "a retry re-stamping `now` would have buried the rejoin");
12511    }
12512
12513    #[tokio::test]
12514    async fn a_boot_with_no_fragments_seeds_the_list_from_local_state() {
12515        let (_tmp, _guard, _owner) = init_test_db();
12516        let relay = MemoryRelay::new();
12517        let relays = vec!["wss://r".to_string()];
12518        let community = create_community(&relay, "Seeded", relays.clone(), None).await.unwrap();
12519
12520        // An account arriving from the retired single-event list: held locally, nothing
12521        // at the fragment coordinate. Boot alone has to write it — no membership changes.
12522        let bare = MemoryRelay::new();
12523        sync_community_list(&bare, &relays).await.unwrap();
12524        let set = fetch_fragments(&bare, &relays).await.unwrap().expect("boot seeded the List");
12525        let live = set.list.live_entries();
12526        assert_eq!(live.len(), 1);
12527        assert_eq!(live[0].community_id, crate::simd::hex::bytes_to_hex_32(&community.id().0));
12528
12529        // Latched: a LATER empty read is a relay that lost the fragments, not a fresh
12530        // account, and republishing local state there would bury a sibling's tombstones.
12531        let empty = MemoryRelay::new();
12532        sync_community_list(&empty, &relays).await.unwrap();
12533        assert_eq!(empty.stored_count(), 0, "the seed runs once");
12534    }
12535
12536    #[tokio::test]
12537    async fn a_dissolved_invite_retires_on_accept_and_tombstones_the_list() {
12538        // The invite to a dissolved community is the one join failure with no
12539        // retry: the refusal is an owner-signed grave. The boundary must retire
12540        // the parked row AND write the §8 tombstone siblings purge on.
12541        let (bed, owner, member) = TestBed::new();
12542        bed.swap_to(&owner);
12543        let community = create_community(&bed.relay, "Grave", bed.relays.clone(), None).await.unwrap();
12544        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
12545        dissolve_community(&bed.relay, &community).await.unwrap();
12546
12547        bed.swap_to(&member);
12548        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12549        crate::db::community::save_pending_invite(&cid_hex, &bundle, "inviter", i64::MAX).unwrap();
12550        let err = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap_err();
12551        assert_eq!(err, ERR_DISSOLVED, "the grave refusal is the sentinel the boundary matches");
12552
12553        retire_dead_invite(&bed.relay, &cid_hex, &bed.relays).await;
12554        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the dead invite leaves this device");
12555        let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the tombstone published");
12556        assert!(set.list.tombstones.iter().any(|t| t.community_id == cid_hex), "and siblings get the §8 tombstone");
12557        assert!(!set.list.is_live(&cid_hex));
12558    }
12559
12560    #[tokio::test]
12561    async fn an_owner_dissolve_tombstones_the_list_for_sibling_devices() {
12562        // Dissolving IS leaving. The grave alone reaches every MEMBER, but only a §8
12563        // tombstone reaches the owner's own sibling devices — without it they keep the
12564        // membership LIVE in the List, fold the grave, and show a sealed husk the owner
12565        // already deleted, on every device, until each is cleaned up by hand.
12566        let (bed, owner, _member) = TestBed::new();
12567        bed.swap_to(&owner);
12568        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
12569        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12570        let before = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("create published the List");
12571        assert!(before.list.is_live(&cid_hex), "the membership is live before the dissolve");
12572
12573        dissolve_community(&bed.relay, &community).await.unwrap();
12574
12575        let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the List outlives the dissolve");
12576        assert!(
12577            set.list.tombstones.iter().any(|t| t.community_id == cid_hex),
12578            "the dissolve published the §8 tombstone alongside the grave"
12579        );
12580        assert!(!set.list.is_live(&cid_hex), "sibling devices tear the husk down on their next list sync");
12581    }
12582
12583    #[tokio::test]
12584    async fn a_sibling_devices_list_tombstone_purges_the_parked_invite() {
12585        // Device A declined (or retired a dead invite): its §8 tombstone must
12586        // clear device B's parked copy on the next list sync — and ONLY when the
12587        // removal post-dates the invite, so a fresh re-invite survives an old leave.
12588        let (_tmp, _guard, _owner) = init_test_db();
12589        let relay = MemoryRelay::new();
12590        let relays = vec!["wss://r".to_string()];
12591        let cid = crate::community::CommunityId([0x5A; 32]);
12592        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
12593
12594        // An OLD tombstone (a past leave) must not kill an invite received after it.
12595        tombstone_community_list(&relay, &cid, &relays, now_ms() - 600_000).await.unwrap();
12596        crate::db::community::save_pending_invite(&cid_hex, "{}", "inviter", i64::MAX).unwrap();
12597        sync_community_list(&relay, &relays).await.unwrap();
12598        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_some(), "a re-invite supersedes a past leave");
12599
12600        // A tombstone NEWER than the invite is a verdict on it: purged on sync.
12601        tombstone_community_list(&relay, &cid, &relays, now_ms() + 10_000).await.unwrap();
12602        sync_community_list(&relay, &relays).await.unwrap();
12603        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the sibling's removal clears the parked copy");
12604    }
12605
12606    #[tokio::test]
12607    async fn an_unchanged_republish_rewrites_no_fragment() {
12608        // Nothing changed since the last write: the rebuilt fragment serializes to
12609        // the bytes already on the relay, so the write must not touch the wire —
12610        // a bumped created_at on identical content is pure churn.
12611        let (_tmp, _guard, _owner) = init_test_db();
12612        let relay = MemoryRelay::new();
12613        let relays = vec!["wss://r".to_string()];
12614        create_community(&relay, "Stable", relays.clone(), None).await.unwrap();
12615        let before = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
12616
12617        republish_community_list(&relay, None).await.unwrap();
12618        let after = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
12619        assert_eq!(before, after, "a byte-identical fragment must not republish");
12620    }
12621
12622    #[tokio::test]
12623    async fn a_frags_tie_at_equal_age_resolves_to_the_larger_count() {
12624        // A torn repack's worst case: two fragments at the SAME created_at
12625        // disagreeing on the total. The larger count must govern (CORD-02 §8) —
12626        // the smaller would push index 2 out of range and its memberships dormant.
12627        let (_tmp, _guard, owner) = init_test_db();
12628        let relay = MemoryRelay::new();
12629        let relays = vec!["wss://r".to_string()];
12630        let at = now_ms() / 1000;
12631        let two = super::super::list_frag::FragList { frags: 2, entries: vec![], tombstones: vec![], extra: Default::default() };
12632        let three = super::super::list_frag::FragList { frags: 3, entries: vec![], tombstones: vec![], extra: Default::default() };
12633        // The larger count sits at the LOWER index: an age-only rule that keeps
12634        // the last fragment seen would pick 2 here, not 3.
12635        for (frag, index) in [(&three, 0usize), (&two, 1usize)] {
12636            let e = super::super::list_frag::build_fragment_event_keys(&owner, frag, index, at).unwrap();
12637            relay.publish(&e, &relays).await.unwrap();
12638        }
12639        let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
12640        assert_eq!(set.declared, 3, "an age tie resolves to the larger frags");
12641        assert!(!set.is_complete(), "index 2 is unread, so the set refuses to write");
12642    }
12643
12644    /// Aggregate fetches answer from the healthy relay; asked alone, the dead
12645    /// relay errors — the partial-failure shape a boot read can't see through.
12646    struct HalfDeaf<'a>(&'a MemoryRelay, String);
12647    #[async_trait::async_trait]
12648    impl Transport for HalfDeaf<'_> {
12649        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12650            self.0.publish(e, r).await
12651        }
12652        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
12653            if relays.len() == 1 && relays[0] == self.1 {
12654                return Err("relay did not answer the fetch".to_string());
12655            }
12656            self.0.fetch(query, relays).await
12657        }
12658        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
12659            self.0.fetch_plane(plane, query, relays).await
12660        }
12661        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12662            self.0.publish_durable(e, r).await
12663        }
12664    }
12665
12666    #[tokio::test]
12667    async fn an_unconfirmed_empty_read_never_seeds() {
12668        // One of the account's relays never answers. The aggregate read comes back
12669        // empty (the healthy relay has nothing), which is indistinguishable from a
12670        // fresh account — but the dead relay may hold a sibling's tombstones, so
12671        // seeding here would bury them. The seed must defer, unlatched, and land
12672        // once every relay confirms empty.
12673        let (_tmp, _guard, _owner) = init_test_db();
12674        let relay = MemoryRelay::new();
12675        let relays = vec!["wss://r".to_string(), "wss://dead".to_string()];
12676        let community = create_community(&relay, "Gated", relays.clone(), None).await.unwrap();
12677        let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12678
12679        let bare = MemoryRelay::new();
12680        let half_deaf = HalfDeaf(&bare, "wss://dead".to_string());
12681        sync_community_list(&half_deaf, &relays).await.unwrap();
12682        assert_eq!(bare.stored_count(), 0, "a failed read must never seed");
12683        assert!(
12684            crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_none(),
12685            "a deferred seed must not latch"
12686        );
12687
12688        // The relay comes back: every relay now confirms empty, and the seed lands.
12689        sync_community_list(&bare, &relays).await.unwrap();
12690        let set = fetch_fragments(&bare, &relays).await.unwrap().expect("the confirmed-empty boot seeds");
12691        assert!(set.list.is_live(&cid));
12692        assert!(crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_some());
12693    }
12694
12695    #[tokio::test]
12696    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
12697        let (_tmp, _guard, _owner) = init_test_db();
12698        let relay = MemoryRelay::new();
12699        let relays = vec!["wss://r".to_string()];
12700        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
12701        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
12702
12703        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
12704        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
12705    }
12706
12707    #[tokio::test]
12708    async fn accepting_the_same_bundle_twice_is_idempotent() {
12709        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
12710        // again must upsert cleanly — same community_id, no duplicate channels, no
12711        // corruption, the keys unchanged.
12712        let (bed, owner, member) = TestBed::new();
12713        bed.swap_to(&owner);
12714        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
12715        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
12716        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12717        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
12718
12719        bed.swap_to(&member);
12720        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
12721        let channels_after_first = first.channels.len();
12722        let root_after_first = first.community_root;
12723
12724        // Accept the identical bundle again (restart / redelivery).
12725        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
12726        assert_eq!(second.id().0, first.id().0, "same community_id");
12727        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
12728        assert_eq!(second.community_root, root_after_first, "root unchanged");
12729
12730        // The persisted state is a single clean community with the expected channels.
12731        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12732        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
12733        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
12734    }
12735
12736    #[tokio::test]
12737    async fn a_fresh_joiner_can_authorize_a_moderators_hide_before_any_follow() {
12738        // The join walk reads the control plane to genesis anyway, and it already
12739        // folds the full roster for the ban gate. Throwing that roster away left
12740        // the fresh member's stored roles EMPTY until the follow worker's queued
12741        // re-walk — and the hide gate reads the stored roster, so every message a
12742        // moderator had hidden rendered to exactly the person who should never
12743        // see it: someone who just walked in.
12744        let (bed, owner, member) = TestBed::new();
12745        let admin = Keys::generate();
12746        bed.swap_to(&owner);
12747        let community = create_community(&bed.relay, "Moderated", bed.relays.clone(), None).await.unwrap();
12748        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
12749        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
12750
12751        // A FRESH account accepts. No follow_control runs in this test — what the
12752        // join itself persisted is all the joiner has.
12753        bed.swap_to(&member);
12754        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
12755        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
12756        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
12757
12758        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12759        assert!(!roster.grants.is_empty(), "the join persisted the roster it already folded");
12760        let owner_hex = joined.owner().unwrap().to_hex();
12761        let victim = Keys::generate().public_key().to_hex();
12762        assert!(
12763            crate::community::moderation::can_hide(Some(&owner_hex), &roster, &admin.public_key().to_hex(), &victim),
12764            "the admin's hides are honorable on the FIRST history ingest, not after a follow"
12765        );
12766        // And the grant's refuse-downgrade floor is seeded, so the citation gate
12767        // (`actor_authority_pinned`) can resolve the same grant.
12768        let grant_hex = crate::simd::hex::bytes_to_hex_32(&crate::community::v2::derive::grant_locator(
12769            joined.id(),
12770            &admin.public_key().to_bytes(),
12771        ));
12772        assert!(
12773            crate::db::community::get_edition_head(&cid_hex, &grant_hex).unwrap().is_some(),
12774            "the admin-signed grant head survived the join"
12775        );
12776    }
12777
12778    #[tokio::test]
12779    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
12780        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
12781        // severs a member; the owner then unbans + sends a FRESH invite carrying the
12782        // NEW root; the member rejoins at the new epoch and converses again. Proves
12783        // a ban is reversible end-to-end, not a one-way door.
12784        let (bed, owner, member) = TestBed::new();
12785        bed.swap_to(&owner);
12786        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
12787        let general = community.channels[0].id;
12788        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
12789        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
12790
12791        bed.swap_to(&member);
12792        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
12793        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
12794        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
12795
12796        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
12797        bed.swap_to(&owner);
12798        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
12799        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
12800        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
12801        assert_eq!(community.root_epoch, Epoch(1));
12802        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
12803
12804        // The member's follow concludes severance (no blob at the new epoch).
12805        bed.swap_to(&member);
12806        let session = crate::db::current_session();
12807        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
12808
12809        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
12810        // directly, so the test picks the NEW invite unambiguously rather than an
12811        // arbitrary one of the two pending 3313s).
12812        bed.swap_to(&owner);
12813        set_banlist(&bed.relay, &community, &[]).await.unwrap();
12814        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12815        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
12816        let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
12817
12818        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
12819        bed.swap_to(&member);
12820        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
12821        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
12822        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
12823        let seen = texts_in(&bed.relay, &rejoined, &general).await;
12824        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
12825        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
12826
12827        bed.swap_to(&owner);
12828        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12829        assert!(
12830            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
12831            "the re-admitted member converses again at the new epoch"
12832        );
12833        // And they're back in the memberlist.
12834        let members = memberlist(&bed.relay, &community).await.unwrap();
12835        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
12836    }
12837
12838    #[tokio::test]
12839    async fn dissolution_blocks_a_join() {
12840        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
12841        // refuses to join.
12842        let (bed, owner, member) = TestBed::new();
12843        bed.swap_to(&owner);
12844        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
12845        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12846        let bundle_json = serde_json::to_string(&bundle).unwrap();
12847        dissolve_community(&bed.relay, &community).await.unwrap();
12848        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
12849
12850        bed.swap_to(&member);
12851        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
12852        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
12853    }
12854
12855    #[tokio::test]
12856    async fn dissolution_seals_writes_but_not_reads() {
12857        // CORD-02 §9: sealed means NO further activity, ever. Reads must survive —
12858        // the history stays browsable, and only explicit user intent deletes it.
12859        let (bed, owner, _member) = TestBed::new();
12860        bed.swap_to(&owner);
12861        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
12862        let general = community.channels[0].id;
12863        send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
12864
12865        dissolve_community(&bed.relay, &community).await.unwrap();
12866        let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12867
12868        for err in [
12869            send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
12870            send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
12871                .await
12872                .unwrap_err(),
12873            send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
12874        ] {
12875            assert!(err.contains("dissolved"), "every write is refused, got: {err}");
12876        }
12877        assert!(
12878            texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
12879            "but the history still reads"
12880        );
12881    }
12882
12883    #[tokio::test]
12884    async fn only_the_owner_can_dissolve() {
12885        let (bed, owner, member) = TestBed::new();
12886        bed.swap_to(&owner);
12887        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
12888        bed.swap_to(&member);
12889        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
12890        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
12891    }
12892
12893    #[tokio::test]
12894    async fn a_foreign_tombstone_is_not_death() {
12895        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
12896        // owner-gated), so the community is not treated as dead.
12897        let (_tmp, _guard, _owner) = init_test_db();
12898        let relay = MemoryRelay::new();
12899        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
12900        let rogue = Keys::generate();
12901        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
12902        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
12903        relay.publish(&wrap, &community.relays).await.unwrap();
12904        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
12905    }
12906
12907    #[tokio::test]
12908    async fn a_public_channel_reads_history_across_a_refounding() {
12909        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
12910        // pre-rotation messages stay readable (the prior epoch's root is archived and
12911        // the read fans out across held epochs).
12912        let (_tmp, _guard, _owner) = init_test_db();
12913        let relay = MemoryRelay::new();
12914        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
12915        let general = community.channels[0].id;
12916        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
12917
12918        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
12919        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
12920        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
12921
12922        let open = texts_in(&relay, &refounded, &general).await;
12923        assert!(open.contains(&"after the refounding".to_string()), "opening reads the live epoch");
12924        assert!(
12925            !open.contains(&"before the refounding".to_string()),
12926            "and NOT the rotated-past one — that plane is the back-paging cursor's job"
12927        );
12928        let paged = all_texts_in(&relay, &refounded, &general).await;
12929        assert!(paged.contains(&"before the refounding".to_string()), "the epoch-0 message stays reachable by paging");
12930        assert!(paged.contains(&"after the refounding".to_string()), "alongside the epoch-1 one");
12931    }
12932
12933    #[tokio::test]
12934    async fn refounding_aborts_when_control_state_is_withheld() {
12935        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
12936        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
12937        // member at the new epoch a fresh joiner bootstraps).
12938        let (_tmp, _guard, owner) = init_test_db();
12939        let relay = MemoryRelay::new();
12940        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
12941        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
12942        follow_control(&relay, &community).await.unwrap().updated; // seed the banlist floor
12943
12944        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
12945        // reloads fresh state) fetches none of the committed heads.
12946        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12947        moved.relays = vec!["wss://empty".into()];
12948        crate::db::community::save_community_v2(&moved).unwrap();
12949
12950        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
12951        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
12952        assert_eq!(
12953            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
12954            Epoch(0),
12955            "the epoch did NOT advance (zero published state)"
12956        );
12957    }
12958
12959    #[tokio::test]
12960    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
12961        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
12962        // epoch advances, and the removed member's rekey-follow concludes they're cut.
12963        let (bed, owner, member) = TestBed::new();
12964        bed.swap_to(&owner);
12965        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
12966        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12967        let bundle_json = serde_json::to_string(&bundle).unwrap();
12968        bed.swap_to(&member);
12969        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12970
12971        bed.swap_to(&owner);
12972        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
12973        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
12974        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
12975        // The owner still reads the compacted control plane at the new epoch.
12976        assert_eq!(
12977            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
12978            Epoch(1),
12979            "the owner committed the new epoch"
12980        );
12981
12982        // The removed member, following rekeys, is severed (no blob in the rotation).
12983        // Guard captured AFTER the swap: it must belong to the ACTING account (the harness
12984        // swap now bumps the generation exactly like a production swap_session).
12985        bed.swap_to(&member);
12986        let session = crate::db::current_session();
12987        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
12988        assert!(follow.self_removed, "the removed member is cut by the re-founding");
12989    }
12990
12991    #[tokio::test]
12992    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
12993        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
12994        // non-owner admin granted BAN CAN re-found (and every member follows it —
12995        // see the receive-side test), but the "strictly outrank every removed
12996        // target" rule still holds: they can't use it to evict the owner.
12997        let (bed, owner, member) = TestBed::new();
12998        bed.swap_to(&owner);
12999        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
13000        let rid = "b0".repeat(32);
13001        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13002        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
13003        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13004        let bundle_json = serde_json::to_string(&bundle).unwrap();
13005        bed.swap_to(&member);
13006        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13007        // Fold the roster so this member's own DB reflects their BAN grant (the
13008        // authority check reads the folded Roster, not the bundle).
13009        let _ = follow_control(&bed.relay, &joined).await;
13010        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13011        // Can't evict the owner (no one outranks the owner).
13012        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
13013        // But CAN re-found removing a plain member they outrank (here, nobody).
13014        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
13015    }
13016
13017    #[tokio::test]
13018    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
13019        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
13020        // follow it — owner-only receive silently strands members whose community
13021        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
13022        // BAN", checked against the folded Roster).
13023        let (bed, owner, me) = TestBed::new();
13024        let admin = Keys::generate();
13025        bed.swap_to(&owner);
13026        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
13027        let rid = "b0".repeat(32);
13028        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13029        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13030
13031        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
13032        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13033        let bundle_json = serde_json::to_string(&bundle).unwrap();
13034        bed.swap_to(&me);
13035        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13036        let _ = follow_control(&bed.relay, &joined).await;
13037        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13038
13039        // The admin re-founds keeping the owner + me — the owner must always be a
13040        // recipient of a non-owner Refounding.
13041        let new_root = [0xC7; 32];
13042        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13043
13044        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13045            .expect("an authorized admin's Refounding is adopted");
13046        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
13047        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
13048    }
13049
13050    #[tokio::test]
13051    async fn adopting_a_severing_rotation_revokes_my_own_links_instead_of_refreshing_them() {
13052        // The inverse of the sibling test: a SEVERING refound flips the stable-URL
13053        // contract. A raid arrives through the door, so every creator's client
13054        // buries its own links on adopt rather than carrying them into the new
13055        // epoch — which is the only way the OWNER's link dies when a bot admin ran
13056        // the containment (only the creator holds a link's signer secret).
13057        let (bed, owner, me) = TestBed::new();
13058        bed.swap_to(&owner);
13059        let community = create_community(&bed.relay, "SeverHeal", bed.relays.clone(), None).await.unwrap();
13060        let rid = "b1".repeat(32);
13061        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13062        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13063
13064        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13065        let bundle_json = serde_json::to_string(&bundle).unwrap();
13066        bed.swap_to(&me);
13067        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13068        let _ = follow_control(&bed.relay, &joined).await;
13069        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13070
13071        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13072        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "my link is live before the raid");
13073
13074        // The owner runs a SEVERING refound (the raid lane).
13075        let new_root = [0xD5; 32];
13076        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13077
13078        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13079            .expect("the owner's severing Refounding is adopted");
13080        assert_eq!(updated.root_epoch, Epoch(1), "I still advance to the new epoch");
13081
13082        let err = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err();
13083        assert!(err.contains("revoked"), "my link must be a grave, not a refreshed door: {err}");
13084        assert!(
13085            crate::db::community::list_public_invites(&crate::simd::hex::bytes_to_hex_32(&updated.id().0)).unwrap().is_empty(),
13086            "and the local record goes with it",
13087        );
13088    }
13089
13090    #[tokio::test(flavor = "multi_thread")]
13091    async fn a_severing_adopt_never_deadlocks_the_last_link_creator() {
13092        // The severance runs INSIDE the follow, which holds `follow_lock` for the
13093        // whole call. `revoke_public_link`'s last-live-link escalation re-founds —
13094        // taking that same non-reentrant lock — so routing severance through it
13095        // would wedge this community's follow forever, for the very client whose
13096        // revocation empties the registry (typically the owner). The escalation-free
13097        // path is the fix; this test is its guard.
13098        let (bed, owner, me) = TestBed::new();
13099        bed.swap_to(&owner);
13100        let community = create_community(&bed.relay, "NoDeadlock", bed.relays.clone(), None).await.unwrap();
13101        let rid = "b2".repeat(32);
13102        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13103        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13104        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13105        let bundle_json = serde_json::to_string(&bundle).unwrap();
13106        bed.swap_to(&me);
13107        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13108        let _ = follow_control(&bed.relay, &joined).await;
13109        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13110        // My link is the ONLY one in the registry — revoking it empties the
13111        // aggregate, which is exactly the escalation's trigger condition.
13112        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13113        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
13114
13115        let new_root = [0xD6; 32];
13116        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13117
13118        let followed = tokio::time::timeout(
13119            std::time::Duration::from_secs(20),
13120            follow_rekeys(&bed.relay, &joined, &crate::db::current_session()),
13121        )
13122        .await
13123        .expect("the severing follow must not deadlock on follow_lock")
13124        .unwrap();
13125        assert_eq!(followed.updated.expect("adopted").root_epoch, Epoch(1));
13126    }
13127
13128    #[tokio::test(flavor = "multi_thread")]
13129    async fn a_severing_adopt_spares_a_link_minted_after_the_raid() {
13130        // Severance is bounded by the rotation's own wire stamp, not "now". A
13131        // second device that adopts late must not eat the fresh link the owner
13132        // minted to re-open the community after the raid was over.
13133        let (bed, owner, me) = TestBed::new();
13134        bed.swap_to(&owner);
13135        let community = create_community(&bed.relay, "FreshDoor", bed.relays.clone(), None).await.unwrap();
13136        let rid = "b3".repeat(32);
13137        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13138        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13139        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13140        let bundle_json = serde_json::to_string(&bundle).unwrap();
13141        bed.swap_to(&me);
13142        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13143        let _ = follow_control(&bed.relay, &joined).await;
13144        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13145        let raid_era = mint_public_link(&bed.relay, &joined, "https://old", None, None).await.unwrap();
13146
13147        let new_root = [0xD7; 32];
13148        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13149        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("adopted");
13150        assert!(
13151            fetch_public_bundle(&bed.relay, &raid_era.url).await.unwrap_err().contains("revoked"),
13152            "the raid-era link is buried",
13153        );
13154
13155        // The raid is over and the owner deliberately re-opens the community. The
13156        // pending-severance retry runs on every follow, so a door minted after the
13157        // containment must survive them all — otherwise no one could ever re-open.
13158        let fresh = mint_public_link(&bed.relay, &updated, "https://new", None, None).await.unwrap();
13159        let _ = follow_rekeys(&bed.relay, &updated, &crate::db::current_session()).await.unwrap();
13160        assert!(
13161            fetch_public_bundle(&bed.relay, &fresh.url).await.is_ok(),
13162            "a link minted after the containment survives later follow passes",
13163        );
13164    }
13165
13166    #[tokio::test(flavor = "multi_thread")]
13167    async fn an_unauthorized_severing_rotation_never_touches_my_links() {
13168        // The sever tag grants no authority of its own: it rides a rotation the
13169        // adopt gate has already verified. A plain member's forged severing
13170        // rotation is refused whole, so the tag is never even read.
13171        let (bed, owner, me) = TestBed::new();
13172        bed.swap_to(&owner);
13173        let community = create_community(&bed.relay, "NoForge", bed.relays.clone(), None).await.unwrap();
13174        let rid = "b4".repeat(32);
13175        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::CREATE_INVITE), 1).await;
13176        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13177        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13178        let bundle_json = serde_json::to_string(&bundle).unwrap();
13179        bed.swap_to(&me);
13180        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13181        let _ = follow_control(&bed.relay, &joined).await;
13182        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13183        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13184
13185        // A stranger with no BAN mints a severing rotation.
13186        let stranger = Keys::generate();
13187        publish_severing_base_rotation(&bed.relay, &joined, &stranger, &[me.keys.public_key(), owner.keys.public_key()], &[0xD8; 32], &joined.community_root).await;
13188
13189        let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13190        assert!(followed.updated.is_none(), "an unauthorized rotation is not adopted");
13191        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "and my link is untouched");
13192    }
13193
13194    #[tokio::test(flavor = "multi_thread")]
13195    async fn a_degraded_plane_read_is_not_evidence_of_absence() {
13196        // One honest relay is enough to RUN Concord — a sealed, signed rotation is
13197        // proof on its own. What one relay's silence is NOT enough for is concluding
13198        // a rotation does not exist. Those two cases used to be the same empty list,
13199        // so a client that could not reach anyone decided the community had simply
13200        // stopped rotating, and parked. Coverage is what separates them, and it is
13201        // measured against the REACHABLE set, so a single-relay community still gets
13202        // definitive answers.
13203        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13204        let relay = AdversarialRelay::new();
13205        let (bed, owner, me) = TestBed::new();
13206        bed.swap_to(&owner);
13207        let community = create_community(relay.relay(), "Coverage", bed.relays.clone(), None).await.unwrap();
13208        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13209        let bundle_json = serde_json::to_string(&bundle).unwrap();
13210        bed.swap_to(&me);
13211        let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13212        let _ = follow_control(relay.relay(), &joined).await;
13213        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13214
13215        bed.swap_to(&owner);
13216        publish_base_rotation(relay.relay(), &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &[0xE9; 32], &joined.community_root).await;
13217        bed.swap_to(&me);
13218
13219        // The plane cannot be reached at all: an Err, not an empty answer.
13220        let plane = base_rekey_group_key(&joined.community_root, joined.id(), Epoch(1));
13221        relay.on(Match::author(&plane.pk_hex()), Fault::FailFetch("relay down".into()));
13222        let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
13223        let during = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13224        assert_eq!(
13225            during.root_epoch, joined.root_epoch,
13226            "an unreachable plane must not advance anything — but it must also not be \
13227             mistaken for a community that stopped rotating",
13228        );
13229
13230        // The relay comes back. The rotation was there the whole time.
13231        relay.heal();
13232        let _ = follow_rekeys(&relay, &during, &crate::db::current_session()).await;
13233        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13234        assert_eq!(
13235            after.root_epoch, Epoch(1),
13236            "once a relay answers, the rotation it was holding is adopted — the earlier \
13237             silence was never evidence",
13238        );
13239    }
13240
13241    #[tokio::test(flavor = "multi_thread")]
13242    async fn an_older_banlist_edition_can_never_un_ban() {
13243        // Un-banning is the fail-open direction: the un-banned party's rotations and
13244        // messages start being honored again. So a stale edition arriving late — a
13245        // slow relay, a replay, a re-fold under a narrowed window — must never
13246        // overwrite a newer list. Enforced at the storage layer so no future folding
13247        // change can reintroduce it.
13248        let (bed, owner, _me) = TestBed::new();
13249        bed.swap_to(&owner);
13250        let community = create_community(&bed.relay, "BanMonotonic", bed.relays.clone(), None).await.unwrap();
13251        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13252        let villain_keys = Keys::generate();
13253        let villain = villain_keys.public_key().to_hex();
13254
13255        crate::db::community::set_community_banlist(&cid_hex, &[villain.clone()], 5).unwrap();
13256        assert!(crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain));
13257
13258        // An OLDER edition (version 3) tries to replace it with an empty list.
13259        crate::db::community::set_community_banlist(&cid_hex, &[], 3).unwrap();
13260        assert!(
13261            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain),
13262            "a lower-versioned edition must not un-ban",
13263        );
13264        assert!(
13265            crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13266            "and the hot cache must not hold the list we declined to persist",
13267        );
13268
13269        // A NEWER edition legitimately un-bans.
13270        crate::db::community::set_community_banlist(&cid_hex, &[], 6).unwrap();
13271        assert!(
13272            !crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13273            "a newer edition still lifts a ban — monotonic, not frozen",
13274        );
13275    }
13276
13277    #[tokio::test(flavor = "multi_thread")]
13278    async fn a_withholding_relay_cannot_wipe_the_banlist_or_the_roster() {
13279        // Absence is not evidence. A relay that serves NO control editions folds an
13280        // empty aggregate, and an empty fold raises no gap flag — nothing is missing
13281        // from a window that contains nothing. Persisting that would un-ban every
13282        // banned member and strip every admin, locally, on one bad fetch. Un-banning
13283        // is the fail-OPEN direction: the un-banned party's rotations start being
13284        // honored again, which is how one client ends up on a branch minted by
13285        // someone everyone else has evicted.
13286        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13287        let relay = AdversarialRelay::new();
13288        let (bed, owner, _me) = TestBed::new();
13289        bed.swap_to(&owner);
13290        let community = create_community(relay.relay(), "WitholdWipe", bed.relays.clone(), None).await.unwrap();
13291        let rid = "c4".repeat(32);
13292        let admin = Keys::generate();
13293        publish_role(relay.relay(), &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13294        publish_grant(relay.relay(), &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13295        let villain = Keys::generate();
13296        set_banlist(relay.relay(), &community, &[villain.public_key().to_hex()]).await.unwrap();
13297        let _ = follow_control(&relay, &community).await;
13298
13299        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13300        assert!(
13301            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13302            "the ban folded",
13303        );
13304        let roster_before = crate::db::community::get_community_roles(&cid_hex).unwrap();
13305        assert!(!roster_before.grants.is_empty(), "the admin's grant folded");
13306
13307        // Now the control plane serves nothing at all.
13308        let control_pk = control::ControlPlane::of(&community).pk_hex();
13309        relay.on(Match::author(&control_pk), Fault::Empty);
13310        let _ = follow_control(&relay, &community).await;
13311
13312        assert!(
13313            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13314            "an empty control window must NEVER un-ban — that is the fail-open direction",
13315        );
13316        let roster_after = crate::db::community::get_community_roles(&cid_hex).unwrap();
13317        assert_eq!(
13318            roster_after.grants.len(), roster_before.grants.len(),
13319            "and must never strip standing: an empty fold is absence, not a demotion",
13320        );
13321    }
13322
13323    #[tokio::test(flavor = "multi_thread")]
13324    async fn a_banned_rotators_refounding_is_never_honored() {
13325        // Holding a key is never authority (CORD-06 §Authority). A member banned
13326        // moments ago still holds the current root, so they can mint a perfectly
13327        // shaped Refounding — right prev_commit, right epoch, real blobs. If a
13328        // client honors it, the banned party has just re-keyed the community around
13329        // itself and split every member who folded the ban from every member who
13330        // did not. The banlist has to be consulted at ADOPT time, not only at send.
13331        let (bed, owner, me) = TestBed::new();
13332        bed.swap_to(&owner);
13333        let community = create_community(&bed.relay, "BannedRotator", bed.relays.clone(), None).await.unwrap();
13334        let rid = "a9".repeat(32);
13335        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13336        let rogue = Keys::generate();
13337        publish_grant(&bed.relay, &community, &owner.keys, &rogue.public_key(), vec![rid], 1).await;
13338        let _ = follow_control(&bed.relay, &community).await;
13339
13340        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13341        let bundle_json = serde_json::to_string(&bundle).unwrap();
13342        bed.swap_to(&me);
13343        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13344        let _ = follow_control(&bed.relay, &joined).await;
13345        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13346
13347        // The rogue admin is banned, and we fold that ban.
13348        bed.swap_to(&owner);
13349        set_banlist(&bed.relay, &community, &[rogue.public_key().to_hex()]).await.unwrap();
13350        bed.swap_to(&me);
13351        let _ = follow_control(&bed.relay, &joined).await;
13352        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13353        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13354        assert!(
13355            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&rogue.public_key().to_hex()),
13356            "the ban folded before the rotation arrives",
13357        );
13358
13359        // Still holding the root, the banned admin mints a Refounding anyway.
13360        bed.swap_to(&owner);
13361        publish_base_rotation(
13362            &bed.relay, &joined, &rogue,
13363            &[owner.keys.public_key(), me.keys.public_key(), rogue.public_key()],
13364            &[0xBA; 32], &joined.community_root,
13365        ).await;
13366
13367        bed.swap_to(&me);
13368        let held_before = joined.root_epoch;
13369        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13370        assert!(!follow.self_removed, "a banned rotator can never remove anyone");
13371        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13372        assert_eq!(
13373            after.root_epoch, held_before,
13374            "a banned member's rotation must not move the epoch — honoring it hands the community to the party just evicted",
13375        );
13376        assert_eq!(after.community_root, joined.community_root, "and must not move the root");
13377    }
13378
13379    #[tokio::test(flavor = "multi_thread")]
13380    async fn a_rekey_exclusion_keeps_my_epoch_keys() {
13381        // Being removed must not cost me the ability to scrub my own history. The
13382        // epoch keys open no FUTURE epoch — post-removal keys are never delivered —
13383        // so retaining them leaks nothing; they are only what lets me author a 3305
13384        // self-delete of messages I already sent, each sealed under the epoch it was
13385        // sent at. Every other self-removal path retains them. The rekey-exclusion
13386        // path silently did not, so an eviction (or a false positive) destroyed that.
13387        let (bed, owner, me) = TestBed::new();
13388        bed.swap_to(&owner);
13389        let community = create_community(&bed.relay, "ExclusionKeys", bed.relays.clone(), None).await.unwrap();
13390        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13391        let bundle_json = serde_json::to_string(&bundle).unwrap();
13392        bed.swap_to(&me);
13393        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13394        let _ = follow_control(&bed.relay, &joined).await;
13395        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13396        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13397        assert!(
13398            !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
13399            "the joiner archived its epoch key",
13400        );
13401
13402        // The owner rotates and leaves me out — a genuine, authorized removal.
13403        bed.swap_to(&owner);
13404        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key()], &[0xD1; 32], &joined.community_root).await;
13405
13406        bed.swap_to(&me);
13407        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13408        assert!(follow.self_removed, "an authorized rotation that excluded me IS a removal");
13409
13410        // The teardown the caller performs on self_removed.
13411        crate::db::community::delete_community_retain_keys(&cid_hex).unwrap();
13412        assert!(
13413            crate::db::community::load_community_v2(joined.id()).unwrap().is_none(),
13414            "the community is gone",
13415        );
13416        assert!(
13417            !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
13418            "but my epoch keys survive, so I can still self-delete what I wrote",
13419        );
13420    }
13421
13422    #[tokio::test(flavor = "multi_thread")]
13423    async fn a_client_that_adopted_the_losing_fork_converges_down_onto_the_canonical_one() {
13424        // Two authorized rotators mint the same epoch concurrently. The tiebreak is
13425        // deterministic — lowest new base key wins — but it only ever ran across
13426        // rotations seen in ONE fetch. A client that saw only the higher sibling
13427        // adopted it and then looked exclusively for its successor, so the canonical
13428        // branch became permanently unreachable: two halves of a community at the
13429        // same epoch number, different keys, unable to read each other, with nobody
13430        // misbehaving. Convergence has to be re-checked, not decided once.
13431        let (bed, owner, me) = TestBed::new();
13432        bed.swap_to(&owner);
13433        let community = create_community(&bed.relay, "ForkHeal", bed.relays.clone(), None).await.unwrap();
13434        let rid = "f1".repeat(32);
13435        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13436        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13437        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13438        let bundle_json = serde_json::to_string(&bundle).unwrap();
13439        bed.swap_to(&me);
13440        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13441        let _ = follow_control(&bed.relay, &joined).await;
13442        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13443
13444        // TWO rotators, which is what a real fork requires: rotations correlate on
13445        // (rotator, scope, epoch, prevcommit), so one rotator minting twice would
13446        // merge into a single rotation rather than fork — and `mint_or_reuse` stops
13447        // an honest rotator doing that anyway. A second BAN-holder is the genuine
13448        // concurrent-refound case. Low wins the spec's tiebreak.
13449        let rival = Keys::generate();
13450        bed.swap_to(&owner);
13451        publish_grant(&bed.relay, &community, &owner.keys, &rival.public_key(), vec!["f1".repeat(32)], 1).await;
13452        // The owner's client must fold the grant before minting the rival's rotation:
13453        // a non-owner rotation carries a `vac` citing the Grant it acts under, and an
13454        // uncited one is inadmissible by design.
13455        let _ = follow_control(&bed.relay, &community).await;
13456        bed.swap_to(&me);
13457        let _ = follow_control(&bed.relay, &joined).await;
13458        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13459
13460        let low = [0x11u8; 32];
13461        let high = [0xF1u8; 32];
13462        let recipients = [owner.keys.public_key(), me.keys.public_key(), rival.public_key()];
13463        bed.swap_to(&owner);
13464        publish_base_rotation(&bed.relay, &joined, &owner.keys, &recipients, &high, &joined.community_root).await;
13465
13466        // We see ONLY the high sibling first and adopt it — the fork is now set.
13467        bed.swap_to(&me);
13468        let session = crate::db::current_session();
13469        let _ = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
13470        let forked = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13471        assert_eq!(forked.root_epoch, Epoch(1), "adopted the sibling we could see");
13472        assert_eq!(forked.community_root, high, "and it is the LOSING branch");
13473
13474        // The rival BAN-holder's sibling surfaces afterwards — a separate rotation,
13475        // and the one the deterministic tiebreak says everyone must land on.
13476        bed.swap_to(&owner);
13477        publish_base_rotation(&bed.relay, &joined, &rival, &recipients, &low, &joined.community_root).await;
13478
13479        bed.swap_to(&me);
13480        let _ = follow_rekeys(&bed.relay, &forked, &session).await.unwrap();
13481        let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13482        assert_eq!(
13483            healed.community_root, low,
13484            "a settled epoch MUST converge down onto the canonical sibling, or the community stays split",
13485        );
13486        assert_eq!(healed.root_epoch, Epoch(1), "same epoch — this is a sideways step, not an advance");
13487
13488        // Down-only: re-running must not bounce back up to the higher sibling, or two
13489        // clients seeing the pair in different orders would oscillate forever.
13490        let _ = follow_rekeys(&bed.relay, &healed, &session).await.unwrap();
13491        let settled = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13492        assert_eq!(settled.community_root, low, "convergence is down-only and stable");
13493    }
13494
13495    #[tokio::test(flavor = "multi_thread")]
13496    async fn a_deep_catch_up_never_loses_ground_and_lands_on_the_head() {
13497        // The scenario this program exists for: a member returns after a community
13498        // rotated hard while they were away, on a link that keeps dropping.
13499        //
13500        // This asserts MONOTONICITY and CONVERGENCE — the epoch never moves
13501        // backward across passes, and the client ends exactly on the head, not one
13502        // short and not overshooting. Retention of a partial walk is a different
13503        // property with its own test (see the interrupted-mid-walk case, which fails
13504        // without per-hop banking); this one would still pass without banking, and
13505        // is not claiming otherwise.
13506        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13507        const HOPS: u64 = 12;
13508
13509        let relay = AdversarialRelay::new();
13510        let (bed, owner, me) = TestBed::new();
13511        bed.swap_to(&owner);
13512        let community = create_community(relay.relay(), "DeepStorm", bed.relays.clone(), None).await.unwrap();
13513        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13514        let bundle_json = serde_json::to_string(&bundle).unwrap();
13515        bed.swap_to(&me);
13516        let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13517        let _ = follow_control(relay.relay(), &joined).await;
13518        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13519
13520        // The community rotates HOPS times while we are offline. Owner-signed, so
13521        // authority is never the variable under test here — reachability is.
13522        bed.swap_to(&owner);
13523        let recipients = [owner.keys.public_key(), me.keys.public_key()];
13524        let mut cur = community.clone();
13525        let mut planes: Vec<String> = Vec::new();
13526        for i in 1..=HOPS {
13527            let root = [(0x40 + i) as u8; 32];
13528            planes.push(base_rekey_group_key(&cur.community_root, cur.id(), Epoch(i)).pk_hex());
13529            publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, &root, &cur.community_root).await;
13530            cur.community_root = root;
13531            cur.root_epoch = Epoch(i);
13532        }
13533
13534        // Come back into a storm. The failures are placed MID-WALK on purpose: each
13535        // pass gets several hops in before dying, so a walk that does not bank as it
13536        // goes loses a batch of work every time and can never get past the first
13537        // barrier. Failing only the first ask of each barrier means the link is
13538        // genuinely recovering, so the only question is whether progress survives.
13539        bed.swap_to(&me);
13540        for barrier in [5usize, 9] {
13541            relay.on(Match::author(&planes[barrier - 1]).nth(1), Fault::FailFetch("flaky link".into()));
13542        }
13543
13544        let session = crate::db::current_session();
13545        let mut last = 0u64;
13546        // Bounded retries — the point is that each pass strictly advances, so a
13547        // finite number of them suffices however unlucky the link is.
13548        for pass in 0..(HOPS * 3) {
13549            let held = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13550            let now = held.root_epoch.0;
13551            assert!(
13552                now >= last,
13553                "pass {pass}: epoch went BACKWARD ({last} -> {now}); a catch-up must never lose ground",
13554            );
13555            last = now;
13556            if now == HOPS {
13557                break;
13558            }
13559            let _ = follow_rekeys(&relay, &held, &session).await;
13560        }
13561
13562        let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13563        assert_eq!(
13564            healed.root_epoch, Epoch(HOPS),
13565            "a deep backlog on a failing link must still converge on the head; stuck at {}",
13566            healed.root_epoch.0,
13567        );
13568    }
13569
13570    #[tokio::test(flavor = "multi_thread")]
13571    async fn a_missing_chunk_does_not_block_adopting_the_key_i_already_hold() {
13572        // Completeness gates ADOPTION today, not just removal. A 1,000-member
13573        // rotation spans thirteen chunks, so one unreachable chunk parks every
13574        // member of the community — including everyone whose key is sitting in a
13575        // chunk they already fetched and can open right now. Requiring the whole set
13576        // to adopt makes a large community's rotation as fragile as its worst chunk,
13577        // and the walk is sequential, so the strand blocks every epoch above it too.
13578        //
13579        // Completeness is required to conclude REMOVAL, which is destructive and
13580        // must stay conservative. Adoption is the safe direction: the blob is bound
13581        // to (scope, epoch) inside its ciphertext, so a key that opens is the key
13582        // for this rotation whatever else is missing.
13583        let (bed, owner, me) = TestBed::new();
13584        bed.swap_to(&owner);
13585        let community = create_community(&bed.relay, "PartialChunks", bed.relays.clone(), None).await.unwrap();
13586        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13587        let bundle_json = serde_json::to_string(&bundle).unwrap();
13588        bed.swap_to(&me);
13589        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13590        let _ = follow_control(&bed.relay, &joined).await;
13591        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13592
13593        // A two-chunk rotation where only the chunk carrying MY blob is published.
13594        bed.swap_to(&owner);
13595        let new_root = [0xC7u8; 32];
13596        let new_epoch = Epoch(joined.root_epoch.0 + 1);
13597        let prev_commit = super::super::derive::epoch_key_commitment(joined.root_epoch, &joined.community_root);
13598        let group = base_rekey_group_key(&joined.community_root, joined.id(), new_epoch);
13599        let my_blob = rekey::build_blob_local(
13600            owner.keys.secret_key(), &owner.keys.public_key().to_bytes(),
13601            &me.keys.public_key(), RekeyScope::Root, new_epoch, &new_root,
13602        ).unwrap();
13603        // Declares 2 chunks; only chunk 1 ever reaches the relay.
13604        let rumor = rekey::build_rekey_rumor(
13605            owner.keys.public_key(), RekeyScope::Root, new_epoch, joined.root_epoch,
13606            &prev_commit, &[my_blob], 1, 2, 2_000,
13607            my_authority_citation(&joined, &owner.keys.public_key()).as_ref(), false,
13608        ).unwrap();
13609        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
13610        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13611
13612        bed.swap_to(&me);
13613        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13614        assert!(!follow.self_removed, "a missing chunk is never a removal");
13615        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13616        assert_eq!(
13617            after.root_epoch, new_epoch,
13618            "my blob was present and opened — the missing sibling chunk says nothing about MY key, \
13619             and parking here strands the whole community behind its least reachable chunk",
13620        );
13621    }
13622
13623    #[tokio::test(flavor = "multi_thread")]
13624    async fn an_empty_rotation_is_never_a_mass_removal() {
13625        // `is_complete` is satisfied by a single chunk declaring n=1 — including one
13626        // carrying ZERO blobs, which the builders emit verbatim for an empty
13627        // recipient set. Read literally that is "a complete, authorized rotation
13628        // containing no blob for me", i.e. removal — and the removal path DELETES the
13629        // community locally, keys and all. From the owner, one such rotation would
13630        // evict every member of the community at once, irreversibly.
13631        //
13632        // Nobody can be removed by a rotation that removed everybody.
13633        let (bed, owner, me) = TestBed::new();
13634        bed.swap_to(&owner);
13635        let community = create_community(&bed.relay, "EmptyRotation", bed.relays.clone(), None).await.unwrap();
13636        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13637        let bundle_json = serde_json::to_string(&bundle).unwrap();
13638        bed.swap_to(&me);
13639        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13640        let _ = follow_control(&bed.relay, &joined).await;
13641        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13642
13643        // The owner publishes a complete rotation with no recipients at all.
13644        bed.swap_to(&owner);
13645        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[], &[0xB0; 32], &joined.community_root).await;
13646
13647        bed.swap_to(&me);
13648        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13649        assert!(
13650            !follow.self_removed,
13651            "an all-empty rotation must NEVER read as my removal — that path deletes the community",
13652        );
13653        assert!(
13654            crate::db::community::load_community_v2(joined.id()).unwrap().is_some(),
13655            "and the community must still be held",
13656        );
13657    }
13658
13659    #[tokio::test(flavor = "multi_thread")]
13660    async fn a_catch_up_interrupted_mid_walk_keeps_the_hops_it_already_took() {
13661        // A client back from a week offline walks many epochs in one pass. Today the
13662        // community row is written ONCE, after the loop, so a fetch failure on the
13663        // last hop discards every hop already walked — the next attempt starts from
13664        // zero, costs the same again, and on a flaky link may never converge at all.
13665        // Progress must be banked per hop.
13666        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13667        // The bed supplies isolated per-account DBs; the adversarial relay replaces
13668        // its transport so a fault plan governs what the client can read back.
13669        let relay = AdversarialRelay::new();
13670        let (bed, owner, me) = TestBed::new();
13671        bed.swap_to(&owner);
13672        let community = create_community(relay.relay(), "DeepCatchUp", bed.relays.clone(), None).await.unwrap();
13673
13674        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13675        let bundle_json = serde_json::to_string(&bundle).unwrap();
13676        bed.swap_to(&me);
13677        let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13678        let _ = follow_control(relay.relay(), &joined).await;
13679        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13680
13681        // Owner rotates three times while we are away. Owner rotations need no roster.
13682        bed.swap_to(&owner);
13683        let mut cur = community.clone();
13684        let roots = [[0xA1u8; 32], [0xA2; 32], [0xA3; 32]];
13685        let recipients = [owner.keys.public_key(), me.keys.public_key()];
13686        for (i, root) in roots.iter().enumerate() {
13687            publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, root, &cur.community_root).await;
13688            cur.community_root = *root;
13689            cur.root_epoch = Epoch(i as u64 + 1);
13690        }
13691
13692        // Come back, but the plane for the LAST hop is unreachable.
13693        bed.swap_to(&me);
13694        let third_hop = base_rekey_group_key(&roots[1], joined.id(), Epoch(3));
13695        relay.on(Match::author(&third_hop.pk_hex()), Fault::FailFetch("relay dropped".into()));
13696
13697        let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
13698
13699        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13700        assert!(
13701            after.root_epoch.0 >= 2,
13702            "hops 1 and 2 succeeded and MUST be banked; an interrupted walk that reverts to epoch {} \
13703             restarts from scratch every attempt and may never converge on a flaky link",
13704            after.root_epoch.0,
13705        );
13706
13707        // Faults cease: the walk resumes from where it banked and reaches the head.
13708        relay.heal();
13709        let _ = follow_rekeys(&relay, &after, &crate::db::current_session()).await;
13710        let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13711        assert_eq!(healed.root_epoch, Epoch(3), "once faults cease the walk converges on the head");
13712    }
13713
13714    #[tokio::test(flavor = "multi_thread")]
13715    async fn a_promote_is_reported_as_an_authority_change() {
13716        // The single line whose absence was the fork. A promote moves the ROSTER,
13717        // which lives in its own column and never appears in the returned document —
13718        // so a caller keying its retry off `updated` alone sees "nothing happened",
13719        // skips the re-walk, and leaves the rekey pass that just refused this admin's
13720        // rotation to never reconsider it. Asserted at the unit level deliberately:
13721        // an integration drill can pass for the wrong reason (a roster cached on some
13722        // earlier pass), and this codebase has been bitten by exactly that before.
13723        let (bed, owner, admin) = TestBed::new();
13724        bed.swap_to(&owner);
13725        let community = create_community(&bed.relay, "PromoteReports", bed.relays.clone(), None).await.unwrap();
13726        follow_control(&bed.relay, &community).await.unwrap(); // settle the baseline
13727
13728        let rid = "e1".repeat(32);
13729        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13730        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
13731
13732        let control = follow_control(&bed.relay, &community).await.unwrap();
13733        assert!(
13734            control.authority_changed,
13735            "a promote MUST report an authority change — this is what re-runs the rekey walk",
13736        );
13737        assert!(control.moved(), "and therefore counts as movement");
13738        assert!(
13739            control.updated.is_none(),
13740            "while moving no document at all — which is precisely why `updated` alone was blind",
13741        );
13742    }
13743
13744    #[tokio::test(flavor = "multi_thread")]
13745    async fn a_seeded_roster_never_vetoes_a_coherent_control_fold() {
13746        // `merge_local_roster` seeds an optimistic roster at `roles_at = 0` so a
13747        // just-created role works before its edition folds. That seed names entities
13748        // the plane may never head, which made `stored_complete` false FOREVER — the
13749        // roster could never be replaced, and a roster that never updates cannot
13750        // authorize anyone, so the client silently refused every rotation that admin
13751        // published. It landed on owners, who seed the most.
13752        let (bed, owner, admin) = TestBed::new();
13753        bed.swap_to(&owner);
13754        let community = create_community(&bed.relay, "SeedVeto", bed.relays.clone(), None).await.unwrap();
13755        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13756        let rid = "c1".repeat(32);
13757        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13758        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
13759
13760        // A seed naming an entity the plane will never head, with NO provenance —
13761        // exactly what creating a private channel leaves behind.
13762        let ghost = "d4".repeat(32);
13763        let mut seeded = crate::community::roles::CommunityRoles::default();
13764        seeded.roles.push(admin_role(&ghost, Permissions::MANAGE_CHANNELS));
13765        seeded.grants.push(crate::community::roles::MemberGrant {
13766            member: owner.keys.public_key().to_hex(),
13767            role_ids: vec![ghost.clone()],
13768        });
13769        crate::db::community::set_community_roles(&cid_hex, &seeded, 0).unwrap();
13770        // A floor for the ghost is what turns `stored_complete` false.
13771        crate::db::community::set_edition_head_at_epoch(&cid_hex, &ghost, 1, &[0u8; 32], &[0u8; 32], community.root_epoch.0).unwrap();
13772
13773        follow_control(&bed.relay, &community).await.unwrap().updated;
13774
13775        let folded = crate::db::community::get_community_roles(&cid_hex).unwrap();
13776        assert!(
13777            folded.grants.iter().any(|g| g.member == admin.keys.public_key().to_hex()),
13778            "a coherent fold must replace a provenance-less seed, or the client is deaf to this admin forever",
13779        );
13780        assert_ne!(
13781            crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
13782            0,
13783            "and the replacement carries the plane's provenance",
13784        );
13785    }
13786
13787    #[tokio::test(flavor = "multi_thread")]
13788    async fn a_pending_severance_heals_on_a_quiet_follow_pass() {
13789        // The retry must run on passes that adopt NOTHING. A one-shot transport
13790        // blip during the original severance otherwise has no healing trigger
13791        // until some future rotation happens to be adopted — and until then the
13792        // community reads Public, which silently strips the refound from every
13793        // ordinary ban.
13794        let (bed, owner, me) = TestBed::new();
13795        bed.swap_to(&owner);
13796        let community = create_community(&bed.relay, "QuietHeal", bed.relays.clone(), None).await.unwrap();
13797        let rid = "b5".repeat(32);
13798        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13799        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13800        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13801        let bundle_json = serde_json::to_string(&bundle).unwrap();
13802        bed.swap_to(&me);
13803        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13804        let _ = follow_control(&bed.relay, &joined).await;
13805        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13806        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13807
13808        // A containment was adopted but its revoke was interrupted: the mark is
13809        // down, the epoch-0 link still stands.
13810        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13811        add_sever_mark(&cid_hex, 1, 5_000);
13812        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
13813
13814        // A QUIET follow — no rotation anywhere — must finish the burial.
13815        let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13816        assert!(followed.updated.is_none(), "nothing to adopt");
13817        assert!(
13818            fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
13819            "the interrupted severance healed with no rotation to carry it",
13820        );
13821    }
13822
13823    #[tokio::test(flavor = "multi_thread")]
13824    async fn a_sever_survives_a_later_ordinary_rotation_in_the_same_walk() {
13825        // Sticky severance: a client catching up walks EVERY missed rotation in
13826        // one pass. If the containment hop is followed by an ordinary one,
13827        // last-hop-wins semantics would refresh the links right back open — the
13828        // door the raid came through, re-armed with the newest root.
13829        let (bed, owner, me) = TestBed::new();
13830        bed.swap_to(&owner);
13831        let community = create_community(&bed.relay, "StickySever", bed.relays.clone(), None).await.unwrap();
13832        let rid = "b6".repeat(32);
13833        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13834        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13835        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13836        let bundle_json = serde_json::to_string(&bundle).unwrap();
13837        bed.swap_to(&me);
13838        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13839        let _ = follow_control(&bed.relay, &joined).await;
13840        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13841        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13842
13843        // Hop 1: the containment. Hop 2: an ordinary rotation on top of it.
13844        let root1 = [0xE1; 32];
13845        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root1, &joined.community_root).await;
13846        let mut stepped = joined.clone();
13847        stepped.community_root = root1;
13848        stepped.root_epoch = Epoch(1);
13849        let root2 = [0xE2; 32];
13850        publish_base_rotation(&bed.relay, &stepped, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root2, &root1).await;
13851
13852        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("both hops adopt");
13853        assert_eq!(updated.root_epoch, Epoch(2), "the walk reached the ordinary head");
13854        assert!(
13855            fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
13856            "the containment's burial survived the ordinary hop behind it",
13857        );
13858    }
13859
13860    /// Did the severing refound vend `who` a key? Opens the base rotation and looks
13861    /// for a blob at their locator.
13862    async fn vended_to(bed: &TestBed, before: &CommunityV2, who: &Keys) -> bool {
13863        let next = Epoch(before.root_epoch.0 + 1);
13864        let group = base_rekey_group_key(&before.community_root, before.id(), next);
13865        let chunks = fetch_rekey_chunks(&bed.relay, &before.relays, &group).await.unwrap();
13866        rekey::collect_rotations(&chunks).iter().any(|r| {
13867            rekey::find_my_blobs(
13868                &r.blobs,
13869                &r.rotator.to_bytes(),
13870                &who.public_key().to_bytes(),
13871                RekeyScope::Root,
13872                next,
13873            )
13874            .next()
13875            .is_some()
13876        })
13877    }
13878
13879    /// Local guestbook row for `who`, claiming `at_ms` but OBSERVED at `seen`.
13880    fn local_join_observed(who: &Keys, at_ms: u64, seen: u64, epoch: u64, tag: u8) -> guestbook::GuestbookEvent {
13881        guestbook::GuestbookEvent {
13882            rumor_id: [tag; 32],
13883            entry: guestbook::GuestbookEntry::Join { member: who.public_key(), invited_by: None, at_ms },
13884            epoch: Some(epoch),
13885            observed_at: Some(seen),
13886        }
13887    }
13888
13889    #[tokio::test(flavor = "multi_thread")]
13890    async fn a_backdated_join_cannot_slide_under_the_window_when_we_watched_it_arrive() {
13891        // `at_ms` is author-claimed, so a raider can put it a year in the past and pass
13892        // any window computed from the claim alone. What this client WATCHED arrive is
13893        // not theirs to write.
13894        let (bed, owner, _o) = TestBed::new();
13895        bed.swap_to(&owner);
13896        let community = create_community(&bed.relay, "Backdate", bed.relays.clone(), None).await.unwrap();
13897        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13898        let liar = Keys::generate();
13899
13900        // Claims it joined long before the window; we saw it arrive INSIDE the window.
13901        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13902        let join = guestbook::build_join_rumor(liar.public_key(), None, 1_000);
13903        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &liar, Timestamp::from_secs(1)).unwrap();
13904        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13905        crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&liar, 1_000, 9_000, community.root_epoch.0, 3)], 0).unwrap();
13906        // We were following well before the window opened.
13907        crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "100".into()).unwrap();
13908
13909        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
13910
13911        assert!(
13912            !vended_to(&bed, &community, &liar).await,
13913            "a backdated Join must not beat the arrival we watched with our own clock",
13914        );
13915    }
13916
13917    #[tokio::test(flavor = "multi_thread")]
13918    async fn a_client_that_started_watching_late_evicts_nobody() {
13919        // Same row, same claim, same observation — but this client only began following
13920        // the guestbook AFTER the window opened, so everything looks new to it. Its
13921        // observations prove nothing, and acting on them would evict the community.
13922        let (bed, owner, _o) = TestBed::new();
13923        bed.swap_to(&owner);
13924        let community = create_community(&bed.relay, "LateWatcher", bed.relays.clone(), None).await.unwrap();
13925        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13926        let quiet = Keys::generate();
13927
13928        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13929        let join = guestbook::build_join_rumor(quiet.public_key(), None, 1_000);
13930        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &quiet, Timestamp::from_secs(1)).unwrap();
13931        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13932        crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&quiet, 1_000, 9_000, community.root_epoch.0, 4)], 0).unwrap();
13933        // Coverage begins INSIDE the window — we were not here before it.
13934        crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "7000".into()).unwrap();
13935
13936        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
13937
13938        assert!(
13939            vended_to(&bed, &community, &quiet).await,
13940            "without coverage predating the window our observations prove nothing — an \
13941             old member must not be evicted for having been noticed late",
13942        );
13943    }
13944
13945    #[tokio::test(flavor = "multi_thread")]
13946    async fn a_purge_vends_only_to_the_retain_list_however_late_a_stranger_arrives() {
13947        // The Admin Terminal hands over a RETAIN list. Internally that used to become a
13948        // removal list computed from the memberlist at one instant, while the refound
13949        // fetched its own later — so an account appearing in between was on neither
13950        // list and got a key by DEFAULT, implicitly retained by the purge meant to be
13951        // exhaustive. An allow-list closes it with no window and nothing to backdate.
13952        let (bed, owner, _o) = TestBed::new();
13953        bed.swap_to(&owner);
13954        let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
13955        let kept = Keys::generate();
13956        let stranger = Keys::generate();
13957        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13958        // The stranger even BACKDATES their Join — an allow-list does not care.
13959        for (who, at_ms) in [(&kept, 2_000_000u64), (&stranger, 1_000u64)] {
13960            let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
13961            let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
13962            bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13963        }
13964
13965        // The operator selected exactly one member. Nobody named the stranger.
13966        refound_community_retaining(&bed.relay, &community, &[], &[kept.public_key()]).await.unwrap();
13967
13968        assert!(
13969            vended_to(&bed, &community, &kept).await,
13970            "the selected member keeps their key",
13971        );
13972        assert!(
13973            !vended_to(&bed, &community, &stranger).await,
13974            "an account not on the retain list is vended NO key, no matter when it \
13975             appeared or what Join time it claims",
13976        );
13977    }
13978
13979    #[tokio::test(flavor = "multi_thread")]
13980    async fn a_raid_window_joiner_is_vended_no_key_even_if_nobody_named_them() {
13981        // The recipient set is a LIVE fetch taken AFTER the cut was computed, and
13982        // re-taken on every retry. A bot arriving in that gap is in the memberlist and
13983        // absent from the cut — and was handed a working key for the very epoch meant
13984        // to evict it. Testing join TIME catches it with nobody naming it.
13985        let (bed, owner, _o) = TestBed::new();
13986        bed.swap_to(&owner);
13987        let community = create_community(&bed.relay, "RaceCut", bed.relays.clone(), None).await.unwrap();
13988        let established = Keys::generate();
13989        let late = Keys::generate();
13990        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
13991        for (who, at_ms) in [(&established, 2_000_000u64), (&late, 9_000_000)] {
13992            let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
13993            let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
13994            bed.relay.publish(&wrap, &bed.relays).await.unwrap();
13995        }
13996
13997        // Window opens at 5_000s. `late` is named by NOBODY — the cut is empty.
13998        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
13999
14000        assert!(
14001            !vended_to(&bed, &community, &late).await,
14002            "a raid-window joiner must be vended NO key, even when nobody named them",
14003        );
14004        assert!(
14005            vended_to(&bed, &community, &established).await,
14006            "and the member who was already here keeps theirs",
14007        );
14008    }
14009
14010    #[tokio::test(flavor = "multi_thread")]
14011    async fn a_member_whose_join_is_not_on_the_plane_is_never_read_as_a_late_joiner() {
14012        // THE regression that matters. A quiet member's Join lives on a buried epoch,
14013        // so the reachable guestbook has no trace of them at all. Absence must read as
14014        // OLD, never as new — reading it the other way shed 39 real members across five
14015        // rotations. Here the Join exists ONLY locally, exactly as a buried one does.
14016        let (bed, owner, _o) = TestBed::new();
14017        bed.swap_to(&owner);
14018        let community = create_community(&bed.relay, "Buried", bed.relays.clone(), None).await.unwrap();
14019        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14020        let quiet = Keys::generate();
14021        let local_join = guestbook::GuestbookEvent {
14022            rumor_id: [7u8; 32],
14023            entry: guestbook::GuestbookEntry::Join { member: quiet.public_key(), invited_by: None, at_ms: 9_900_000 },
14024            epoch: Some(community.root_epoch.0),
14025            observed_at: None,
14026        };
14027        crate::db::community::set_guestbook(&cid_hex, &[local_join], 0).unwrap();
14028
14029        // Even stamped INSIDE the window, a Join the plane does not serve must not evict.
14030        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14031
14032        assert!(
14033            vended_to(&bed, &community, &quiet).await,
14034            "no Join on the reachable plane means OLD — absence of evidence must never evict",
14035        );
14036    }
14037
14038    #[tokio::test(flavor = "multi_thread")]
14039    async fn contain_raid_bans_cuts_and_severs_end_to_end() {
14040        let (bed, owner, _other) = TestBed::new();
14041        bed.swap_to(&owner);
14042        let community = create_community(&bed.relay, "Containment", bed.relays.clone(), None).await.unwrap();
14043        let minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
14044
14045        // Three arrivals: an established member (before the window), a convicted
14046        // raider and a quiet lurker (both inside it).
14047        let established = Keys::generate();
14048        let raider = Keys::generate();
14049        let lurker = Keys::generate();
14050        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14051        for (who, at_ms) in [(&established, 2_000_000u64), (&raider, 9_000_000), (&lurker, 9_500_000)] {
14052            let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14053            let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14054            bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14055        }
14056
14057        let report = contain_raid(&bed.relay, community.id(), &[raider.public_key()], 5_000, &[]).await.unwrap();
14058        assert_eq!(report.banned, 1, "the convicted raider is banned");
14059        assert_eq!(report.window_cut, 1, "the lurker who joined inside the window is cut");
14060        assert!(report.refound_ok, "the severing refound landed: {:?}", report.warnings);
14061        assert_eq!((report.epoch_before, report.epoch_after), (0, 1), "the root rolled");
14062        assert_eq!(report.own_links_revoked, 1, "the door closed with it");
14063
14064        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14065        let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
14066        assert!(banlist.contains(&raider.public_key().to_hex()), "raider on the banlist");
14067        assert!(!banlist.contains(&lurker.public_key().to_hex()), "the lurker is cut, never banned — they can rejoin through a fresh link");
14068        assert!(!banlist.contains(&established.public_key().to_hex()));
14069        assert!(
14070            fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14071            "the invite URL the raid used is a grave",
14072        );
14073
14074        // The rotation's recipient set is the severance line: the established
14075        // member rides into the new epoch, the raider and the lurker do not.
14076        let updated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14077        assert_eq!(updated.root_epoch, Epoch(1));
14078        let members = memberlist(&bed.relay, &updated).await.unwrap();
14079        assert!(members.contains(&established.public_key()), "established member survives");
14080        assert!(!members.contains(&raider.public_key()), "raider gone");
14081    }
14082
14083    #[tokio::test(flavor = "multi_thread")]
14084    async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
14085        // CORD-05 §2: a link shared once keeps working across rotations, because
14086        // its bundle is re-posted behind the same URL. The Refounder can only
14087        // refresh the bundles they hold signer secrets for — their OWN — so
14088        // every other creator has to heal their links when they ADOPT the
14089        // rotation. Without that, an admin's links keep vending the superseded
14090        // root and drop new joiners onto a dead epoch, which is precisely the
14091        // stranding the stable-URL refresh exists to prevent.
14092        let (bed, owner, me) = TestBed::new();
14093        bed.swap_to(&owner);
14094        let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
14095        let rid = "b1".repeat(32);
14096        // CREATE_INVITE too: minting is offer-gated by the same bit readers use.
14097        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14098        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14099
14100        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14101        let bundle_json = serde_json::to_string(&bundle).unwrap();
14102        bed.swap_to(&me);
14103        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14104        let _ = follow_control(&bed.relay, &joined).await;
14105        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14106
14107        // I mint a link of my own at the CURRENT epoch.
14108        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14109        let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14110        assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
14111
14112        // The OWNER re-founds. Their refresh can't touch my bundle: only I hold
14113        // its signer secret.
14114        let new_root = [0xD4; 32];
14115        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14116
14117        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
14118            .expect("the owner's Refounding is adopted");
14119        assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
14120
14121        let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14122        assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
14123        assert_eq!(
14124            crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
14125            new_root,
14126            "and the new root behind the same URL",
14127        );
14128    }
14129
14130    #[tokio::test]
14131    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
14132        // Authority escalation: a BAN-admin can't use a Refounding to evict the
14133        // OWNER (no one outranks the owner). Excluding them makes the rotation
14134        // inadmissible — members fork-reject it rather than migrate to the coup.
14135        let (bed, owner, me) = TestBed::new();
14136        let admin = Keys::generate();
14137        bed.swap_to(&owner);
14138        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
14139        let rid = "b0".repeat(32);
14140        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14141        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
14142
14143        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14144        let bundle_json = serde_json::to_string(&bundle).unwrap();
14145        bed.swap_to(&me);
14146        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14147        let _ = follow_control(&bed.relay, &joined).await;
14148        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14149
14150        // The admin re-founds delivering to me but NOT the owner — a takeover.
14151        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
14152        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14153        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
14154    }
14155
14156    #[tokio::test]
14157    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
14158        // Authority escalation: two equal-rank BAN-admins — neither strictly
14159        // outranks the other, so one can't Refound the other out. Excluding a
14160        // peer makes the rotation inadmissible.
14161        let (bed, owner, me) = TestBed::new();
14162        let admin_a = Keys::generate();
14163        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
14164        bed.swap_to(&owner);
14165        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
14166        let rid = "b0".repeat(32);
14167        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14168        // Both A and B hold the SAME role (same position 1) → peers.
14169        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
14170        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
14171
14172        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14173        let bundle_json = serde_json::to_string(&bundle).unwrap();
14174        bed.swap_to(&me);
14175        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14176        let _ = follow_control(&bed.relay, &joined).await;
14177        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14178
14179        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
14180        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
14181
14182        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14183        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
14184    }
14185
14186    #[tokio::test]
14187    async fn a_retried_refounding_reuses_the_same_root() {
14188        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
14189        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
14190        let (_tmp, _guard, _owner) = init_test_db();
14191        let relay = MemoryRelay::new();
14192        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
14193        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14194        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14195        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14196        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
14197    }
14198
14199    #[tokio::test]
14200    async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
14201        // CORD-04 §2 rank inversion. Minting at a position you outrank is
14202        // necessary but NOT sufficient: an edition replaces the entity, so a
14203        // gate that only reads the NEW position lets an admin at position 5
14204        // rewrite the position-1 role to position 9. Every check passes (9 is
14205        // beneath them), and the role that outranked them — plus everyone
14206        // holding it — is now beneath them.
14207        let (bed, owner, attacker) = TestBed::new();
14208        bed.swap_to(&owner);
14209        let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
14210
14211        // A senior role at position 1, and a mid role at position 5 the attacker holds.
14212        let senior = "a1".repeat(32);
14213        let mid = "a5".repeat(32);
14214        publish_role(&bed.relay, &community, &owner.keys,
14215            &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
14216        publish_role(&bed.relay, &community, &owner.keys,
14217            &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
14218        publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
14219
14220        // The attacker republishes the SENIOR role, dropping it beneath themselves.
14221        publish_role(&bed.relay, &community, &attacker.keys,
14222            &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
14223
14224        let authority = fetch_authority(&bed.relay, &community).await;
14225        let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
14226        assert_eq!(
14227            folded_senior.position, 1,
14228            "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
14229        );
14230    }
14231
14232    #[tokio::test]
14233    async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
14234        // CORD-04 §5. Armada's reader REQUIRES this on every non-owner control
14235        // edition (`citationOk`: "a non-owner action MUST cite its grant"), so
14236        // an uncited Vector admin's ban/role/channel edit was silently dropped
14237        // by every Armada client — only the owner's actions crossed. The
14238        // citation must name the actor's OWN grant coordinate, at the version
14239        // and edition hash the verifier can match against a grant it holds.
14240        let (bed, owner, admin) = TestBed::new();
14241        bed.swap_to(&owner);
14242        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
14243        let rid = "c1".repeat(32);
14244        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
14245        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14246
14247        // The owner's own edition carries NO citation: their rank is the id.
14248        let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
14249        edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
14250        let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
14251            .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
14252            .max_by_key(|e| e.version).expect("the owner's metadata edition");
14253        assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
14254
14255        // The admin JOINS and folds — the citation names the grant head their own
14256        // client has actually synced, so the fold must have persisted it.
14257        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14258        let bundle_json = serde_json::to_string(&bundle).unwrap();
14259        bed.swap_to(&admin);
14260        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14261        let _ = follow_control(&bed.relay, &joined).await;
14262        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14263        set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
14264
14265        let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
14266            .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
14267            .expect("the admin's banlist edition");
14268        let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
14269        assert_eq!(
14270            cite.entity_id,
14271            crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
14272            "the citation must name the ACTOR'S OWN grant coordinate",
14273        );
14274        assert!(cite.version >= 1, "pinned to a real grant version");
14275    }
14276
14277    #[tokio::test]
14278    async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
14279        // `cap_relays` is the truncate-on-read invariant everywhere else, and the
14280        // fold is a boundary like any other: MANAGE_METADATA makes an editor
14281        // authorized, not trusted. An oversize list costs every member a fan-out
14282        // per publish and the slowest of N per fetch — and Armada caps at 5, so
14283        // an uncapped fold also splits the two clients' operative sets.
14284        let (_tmp, _guard, _owner) = init_test_db();
14285        let relay = MemoryRelay::new();
14286        let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
14287
14288        let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
14289        let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
14290        edit_community_metadata(&relay, &community, &meta).await.unwrap();
14291
14292        let updated = follow_control(&relay, &community).await.unwrap().updated
14293            .expect("the metadata edition is folded");
14294        assert_eq!(
14295            updated.relays.len(),
14296            crate::community::MAX_COMMUNITY_RELAYS,
14297            "a folded relay list must be truncated, never adopted whole",
14298        );
14299
14300        // …and the fold must SETTLE: comparing an oversize edition against the
14301        // capped working set would never be equal, so every later fold would
14302        // report a change and re-save forever.
14303        let again = follow_control(&relay, &updated).await.unwrap().updated;
14304        assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
14305    }
14306
14307    #[tokio::test]
14308    async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
14309        // One Invite List spans every community, so "I hold links" must never be
14310        // read as "I hold links HERE". A member with links elsewhere adopting a
14311        // rotation would otherwise publish an empty Registry edition into this
14312        // community — a control-plane write and a version bump on a coordinate
14313        // they never owned, every rotation, forever.
14314        let (bed, owner, me) = TestBed::new();
14315        bed.swap_to(&owner);
14316        let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
14317        let rid = "b2".repeat(32);
14318        publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14319        publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14320
14321        let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14322        let bundle_json = serde_json::to_string(&bundle).unwrap();
14323        bed.swap_to(&me);
14324        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14325        let _ = follow_control(&bed.relay, &joined).await;
14326        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14327
14328        // My only link lives in a DIFFERENT community — one I own, since minting
14329        // is offer-gated on CREATE_INVITE.
14330        let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
14331        mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
14332
14333        let before = bed.relay.stored_count();
14334        let new_root = [0xE1; 32];
14335        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14336        let rotation_events = bed.relay.stored_count() - before;
14337
14338        let after_adopt = bed.relay.stored_count();
14339        follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14340        assert_eq!(
14341            bed.relay.stored_count(),
14342            after_adopt,
14343            "adopting a rotation must publish NOTHING when I minted no links here",
14344        );
14345        assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
14346    }
14347
14348    #[tokio::test]
14349    async fn an_expired_link_stops_keeping_the_community_public() {
14350        // CORD-05 §1/§5: expiry is the one way a link dies with no user action.
14351        // A joiner is refused by `InviteBundle::expired`, so leaving the link in
14352        // the Registry states a door that isn't there — the aggregate never
14353        // empties and the community reads Public forever, silently inverting
14354        // every gate that hangs off that reading.
14355        let (_tmp, _guard, _owner) = init_test_db();
14356        let relay = MemoryRelay::new();
14357        let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
14358
14359        // A link that lapsed a minute ago.
14360        let past = now_ms() - 60_000;
14361        mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
14362        assert!(
14363            !community_is_public(&relay, &community).await,
14364            "an already-expired link must never read as a live door",
14365        );
14366
14367        // …and one that hasn't, to prove the filter isn't just dropping everything.
14368        mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
14369        assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
14370    }
14371
14372    #[tokio::test]
14373    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
14374        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
14375        // link publishes it (Public); retiring the last link empties it (Private).
14376        let (_tmp, _guard, _owner) = init_test_db();
14377        let relay = MemoryRelay::new();
14378        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
14379        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
14380
14381        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
14382        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
14383        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
14384        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
14385
14386        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
14387        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
14388        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
14389        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
14390        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
14391    }
14392
14393    #[tokio::test]
14394    async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
14395        // Every caller reads the `invite_registry` COLUMN, never the async fold. v2
14396        // published the Registry to the plane but never mirrored it locally, so every
14397        // v2 community read Private no matter how many live links it had.
14398        let (_tmp, _guard, _owner) = init_test_db();
14399        let relay = MemoryRelay::new();
14400        let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
14401        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14402        let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
14403        // The per-creator split is a SEPARATE table, and it drives the "first link flips
14404        // the community Public" confirm — an empty one re-asks on every later link.
14405        let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
14406        assert!(cached().is_empty(), "a fresh community caches an empty registry");
14407        assert!(per_creator().is_empty(), "…and no per-creator sets");
14408
14409        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
14410        assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
14411        let sets = per_creator();
14412        assert_eq!(sets.len(), 1, "the minting creator gets a set");
14413        assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
14414
14415        // Both caches must SHRINK too — a union-only mirror would strand it Public.
14416        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
14417        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
14418        assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
14419        assert!(per_creator().is_empty(), "…and clears the per-creator sets");
14420    }
14421
14422    #[tokio::test]
14423    async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
14424        // Registries are coordinate-bound to their creator, but `fold_head` picks an
14425        // equal-version winner AUTHOR-BLIND, by lowest inner id — and an author grinds
14426        // that freely by varying content. Folding before authorising would let any
14427        // member occupy the owner's registry head, fail the authority check, and drop
14428        // the whole registry: a live invite link silently retired, flipping the
14429        // community to Private and steering a moderator into the wrong ban remedy.
14430        let (_tmp, _guard, owner) = init_test_db();
14431        let relay = MemoryRelay::new();
14432        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
14433        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
14434        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
14435
14436        let cid = community.id();
14437        let control = control::ControlPlane::of(&community).write_group().unwrap();
14438        let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
14439
14440        let query = Query {
14441            kinds: vec![stream::KIND_WRAP],
14442            authors: vec![control.pk_hex()],
14443            limit: Some(FOLLOW_PAGE),
14444            ..Default::default()
14445        };
14446        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
14447        let target = wraps
14448            .iter()
14449            .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
14450            .filter(|e| e.entity_id == eid)
14451            .max_by_key(|e| e.version)
14452            .expect("the owner published a registry");
14453
14454        // Grind a same-version fork under the owner's coordinate that OUTRANKS the
14455        // real head on the tiebreak (~2 tries against a uniform id).
14456        let rogue = Keys::generate();
14457        let mut planted = false;
14458        for n in 0..4_000u64 {
14459            let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
14460            let rumor = control::build_edition_rumor(
14461                rogue.public_key(),
14462                vsk::INVITE_LINKS,
14463                &eid,
14464                target.version,
14465                target.prev_hash.as_ref(),
14466                &content,
14467                9_000,
14468                None,
14469            );
14470            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
14471            let (ed, _) = control::open_control_edition(&w, &control).unwrap();
14472            if ed.inner_id < target.inner_id {
14473                relay.publish(&w, &community.relays).await.unwrap();
14474                planted = true;
14475                break;
14476            }
14477        }
14478        assert!(planted, "the test needs a fork that wins the tiebreak");
14479
14480        assert!(
14481            community_is_public(&relay, &community).await,
14482            "an unauthorised fork must not retire the owner's live link"
14483        );
14484    }
14485
14486    #[tokio::test]
14487    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
14488        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
14489        let (_tmp, _guard, owner) = init_test_db();
14490        let relay = MemoryRelay::new();
14491        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
14492        let rogue = Keys::generate();
14493        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
14494        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
14495        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
14496        let group = control::ControlPlane::of(&community).write_group().unwrap();
14497        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
14498        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
14499        relay.publish(&wrap, &community.relays).await.unwrap();
14500        let _ = owner;
14501        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
14502    }
14503
14504    #[tokio::test]
14505    async fn full_lifecycle_e2e() {
14506        // The whole stack end to end across two accounts: create -> Public link ->
14507        // owner grants an admin -> member joins + reads history -> admin edits metadata
14508        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
14509        // Refounding) -> the banned member is severed AND stays banned across the new
14510        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
14511        let (bed, owner, member) = TestBed::new();
14512
14513        bed.swap_to(&owner);
14514        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
14515        let general = community.channels[0].id;
14516        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
14517
14518        // Public link → the community reads Public.
14519        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
14520        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
14521
14522        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
14523        let rid = "aa".repeat(32);
14524        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
14525        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
14526
14527        // Member joins from the bundle + reads the owner's message.
14528        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14529        let bundle_json = serde_json::to_string(&bundle).unwrap();
14530        bed.swap_to(&member);
14531        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14532        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
14533        // The admin folds first — adopting the staff write key their Grant
14534        // delivered (CORD-04 §3) — then renames the community.
14535        let _ = follow_control(&bed.relay, &joined).await;
14536        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14537        assert!(joined.control_root.is_some(), "the Grant's control_wrap was adopted on fold");
14538        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
14539
14540        // Owner follows: the admin's rename folds (authorized).
14541        bed.swap_to(&owner);
14542        let updated = follow_control(&bed.relay, &community).await.unwrap().updated.expect("the admin edit folds");
14543        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
14544
14545        // Ban the member (the three-removal composition, in order).
14546        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
14547        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
14548        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
14549        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
14550        // The ban survives the Refounding (the banlist head compacted forward).
14551        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14552        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
14553        // Pre-ban history still reads across the new epoch.
14554        assert!(
14555            all_texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
14556            "pre-refounding history stays reachable by paging"
14557        );
14558
14559        // The banned member's rekey-follow concludes they're severed. Guard captured AFTER
14560        // the swap (the harness swap bumps the generation like production).
14561        bed.swap_to(&member);
14562        let session = crate::db::current_session();
14563        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14564        assert!(follow.self_removed, "the banned member is cryptographically cut");
14565
14566        // Owner dissolves → sealed.
14567        bed.swap_to(&owner);
14568        dissolve_community(&bed.relay, &refounded).await.unwrap();
14569        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
14570    }
14571
14572    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
14573    /// over one shared relay, create → channels (public + private) → converse both ways →
14574    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
14575    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
14576    /// doubles as a reference transcript and a re-runnable regression.
14577    #[tokio::test]
14578    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
14579        // A member forges a higher-version role edition at the admin coordinate before a
14580        // refounding. The compaction must carry the AUTHORIZED floor head, not the
14581        // author-blind version tip — else the forgery is re-anchored, honest folders drop
14582        // it, and the admin role vanishes at the new epoch (silent suppression).
14583        let (bed, owner, member) = TestBed::new();
14584        let attacker = Keys::generate();
14585        bed.swap_to(&owner);
14586        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
14587        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
14588        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
14589        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
14590        // Owner folds → the authorized role/grant heads are floored.
14591        follow_control(&bed.relay, &community).await.unwrap().updated;
14592        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
14593
14594        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
14595        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;
14596
14597        // Owner refounds (keeping everyone).
14598        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
14599        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
14600
14601        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
14602        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14603        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
14604    }
14605
14606    #[tokio::test]
14607    async fn memberlist_survives_a_refounding_via_the_snapshot() {
14608        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
14609        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
14610        let (bed, owner, member) = TestBed::new();
14611        bed.swap_to(&owner);
14612        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
14613
14614        // Member joins (a Guestbook Join at epoch 0).
14615        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
14616        bed.swap_to(&member);
14617        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
14618        bed.swap_to(&owner);
14619        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
14620
14621        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
14622        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
14623        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
14624
14625        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
14626        let members = memberlist(&bed.relay, &refounded).await.unwrap();
14627        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
14628        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
14629    }
14630
14631    #[tokio::test]
14632    async fn e2e_two_accounts_channels_converse_moderate() {
14633        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
14634        use nostr_sdk::prelude::ToBech32;
14635        let (bed, a, b) = TestBed::new();
14636        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
14637        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
14638        println!("\n===== Concord v2 deep e2e =====");
14639        println!("[acct] A (owner)  = {a_npub}");
14640        println!("[acct] B (member) = {b_npub}");
14641
14642        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
14643        bed.swap_to(&a);
14644        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
14645        let general = community.channels[0].id;
14646        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
14647
14648        // A PRIVATE channel via the REAL create path: an independent key minted at
14649        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
14650        // then announced (vsk 2) — later carried to B in the join bundle.
14651        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
14652        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14653        let priv_ch = community.channel(&priv_id).unwrap();
14654        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
14655        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
14656
14657        // Two more PUBLIC channels via the real create path.
14658        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
14659        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14660        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
14661        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14662        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
14663        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
14664
14665        // A talks in a few channels.
14666        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
14667        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
14668        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
14669        println!("[msg] A posted in #general / #announcements / #mods");
14670
14671        // ── A grants B admin, mints a public link, B joins from the bundle ──
14672        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
14673        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
14674        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
14675        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
14676        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
14677        println!("[invite] granted B @admin · minted link {}", link.url);
14678
14679        // A private channel is readable only by granted role-holders (CORD-03), so
14680        // B is added to its access list before the bundle is minted.
14681        grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
14682        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
14683        bed.swap_to(&b);
14684        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14685        println!("[join] B joined; sees {} channels", b_view.channels.len());
14686        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
14687        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");
14688        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
14689        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");
14690        // B folds the control plane (persisting the roster) — the live worker does
14691        // this right after any join; B's admin standing gates B's channel ops below.
14692        if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
14693            b_view = fresh;
14694        }
14695        println!("[follow] B folded control (roster persisted: B is @admin)");
14696
14697        // ── Conversation both ways + persistence (get_messages-level) ──
14698        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
14699        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
14700        println!("[msg] B replied in #general + #mods");
14701        // Persist B's own #general view into the shared store (what sync/live ingest does)
14702        // and confirm it reads back via STATE — get_messages parity.
14703        let my_pk = b.keys.public_key();
14704        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
14705        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
14706            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
14707            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
14708        }
14709        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
14710        println!("[persist] #general history persisted into the shared events store");
14711
14712        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
14713        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
14714        bed.swap_to(&a);
14715        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
14716        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
14717        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
14718        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
14719        println!("[ops] reaction + edit + delete round-tripped");
14720
14721        // ── B creates a channel as admin, A folds it in ──
14722        bed.swap_to(&b);
14723        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
14724        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
14725        bed.swap_to(&a);
14726        if let Some(updated) = follow_control(&bed.relay, &community).await.unwrap().updated {
14727            community = updated;
14728        }
14729        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
14730        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
14731
14732        // ── A creates a SECOND private channel while B is already a member. B is
14733        // NOT on its access list, so B learns the channel exists (control-follow,
14734        // keyless) and gets no key: CORD-03's private channel is readable only by
14735        // granted role-holders, never by every member. B keys up if and when A
14736        // grants them the channel's access role and vends the key ──
14737        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
14738        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14739        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
14740        println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
14741        bed.swap_to(&b);
14742        let session_b2 = crate::db::current_session();
14743        if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
14744            b_view = fresh;
14745        }
14746        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
14747        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
14748        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
14749        if let Some(fresh) = rf.updated {
14750            b_view = fresh;
14751        }
14752        let ch = b_view.channel(&vault).expect("still recorded");
14753        assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
14754        assert!(
14755            texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
14756            "and reads nothing from it"
14757        );
14758        assert!(
14759            send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
14760            "an unentitled member cannot post into the channel either"
14761        );
14762        bed.swap_to(&a);
14763        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14764        println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
14765
14766        // ── Members ──
14767        let members = memberlist(&bed.relay, &community).await.unwrap();
14768        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
14769        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
14770        println!("[members] {} members: A + B present", members.len());
14771
14772        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
14773        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
14774        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
14775        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
14776        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
14777        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14778        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
14779        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");
14780        assert!(
14781            all_texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
14782            "PRIVATE history pages back across the channel's own rotation (per-channel multi-epoch archive)"
14783        );
14784        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
14785        // B concludes it's severed.
14786        bed.swap_to(&b);
14787        let session_b3 = crate::db::current_session();
14788        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
14789        println!("[ban] B's rekey-follow: self_removed = true (severed)");
14790
14791        // ── Unban: A lifts the ban ──
14792        bed.swap_to(&a);
14793        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
14794        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
14795        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
14796        println!("[unban] B removed from the banlist (re-invitable)");
14797
14798        // ── Dissolve ──
14799        dissolve_community(&bed.relay, &refounded).await.unwrap();
14800        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
14801        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
14802    }
14803
14804    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
14805    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
14806    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
14807    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
14808    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
14809    #[tokio::test]
14810    #[ignore]
14811    async fn live_e2e_two_accounts() {
14812        use crate::community::transport::LiveTransport;
14813        use nostr_sdk::prelude::ToBech32;
14814
14815        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
14816        let relays = vec![relay.clone()];
14817        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
14818        crate::db::close_database();
14819        crate::db::clear_id_caches();
14820        let tmp = tempfile::tempdir().unwrap();
14821        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
14822
14823        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
14824        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
14825        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
14826
14827        let log = |line: String| {
14828            println!("{line}");
14829            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
14830                use std::io::Write;
14831                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
14832                    let _ = writeln!(f, "{line}");
14833                }
14834            }
14835        };
14836        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
14837        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
14838        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
14839
14840        for k in [&a, &b] {
14841            let npub = k.public_key().to_bech32().unwrap();
14842            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
14843            crate::db::set_current_account(npub.clone()).unwrap();
14844            crate::db::init_database(&npub).unwrap();
14845        }
14846        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
14847        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
14848        let client = crate::nostr_client_builder().build();
14849        client.add_managed_relay(relay.as_str()).await.ok();
14850        client.connect().await;
14851        crate::state::set_nostr_client(client);
14852        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
14853        let become_acct = |k: &Keys| {
14854            let npub = k.public_key().to_bech32().unwrap();
14855            crate::db::set_current_account(npub.clone()).unwrap();
14856            crate::db::init_database(&npub).unwrap();
14857            crate::db::clear_id_caches();
14858            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
14859            crate::state::set_my_public_key(k.public_key());
14860        };
14861        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
14862
14863        // A: create + a channel + grant B admin + mint link.
14864        become_acct(&a);
14865        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
14866        let general = community.channels[0].id;
14867        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
14868        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
14869        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
14870        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14871        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
14872        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
14873        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
14874        log(format!("[invite] B granted @admin · link {}", link.url));
14875        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
14876        settle().await;
14877
14878        // B: join + read A's history + reply.
14879        become_acct(&b);
14880        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
14881        log(format!("[join] B joined; {} channels", b_view.channels.len()));
14882        settle().await;
14883        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
14884        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
14885        log(format!("[read] B sees #general: {seen:?}"));
14886        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
14887        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
14888
14889        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
14890        // sends) directly onto the chat plane — proving the cross-client thread
14891        // RECEIVE path works live, not just in the offline fixture.
14892        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
14893        let hello_id = hello.event.opened().rumor_id.to_hex();
14894        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
14895        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
14896        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());
14897        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
14898        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
14899        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
14900        settle().await;
14901
14902        // A reads the thread reply back, rendered inline with A's message as parent.
14903        become_acct(&a);
14904        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14905        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
14906        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
14907        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
14908            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
14909            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");
14910        } else {
14911            panic!("the 1111 parsed as a Message");
14912        }
14913        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
14914        become_acct(&b);
14915        settle().await;
14916
14917        // A: create a PRIVATE channel while B is already a member — B is a recipient
14918        // of the creation delivery, so B keys up from the rekey plane over the real
14919        // relay (no bundle involved), then the two converse on it.
14920        become_acct(&a);
14921        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14922        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
14923        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14924        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
14925        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
14926        settle().await;
14927
14928        become_acct(&b);
14929        let session_b = crate::db::current_session();
14930        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
14931        if let Some(fresh) = follow_control(&transport, &b_view).await.expect("B control follow").updated {
14932            b_view = fresh;
14933        }
14934        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
14935            b_view = fresh;
14936        }
14937        let vch = b_view.channel(&vault).expect("B folded the vault");
14938        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
14939        let vseen = texts_in(&transport, &b_view, &vault).await;
14940        log(format!("[read] B sees #vault: {vseen:?}"));
14941        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
14942        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
14943        settle().await;
14944
14945        become_acct(&a);
14946        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14947        assert!(
14948            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
14949            "A reads B's private reply"
14950        );
14951        log("[private] two-way #vault conversation over the live relay".to_string());
14952
14953        // A: ban B (three-removal) + dissolve.
14954        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
14955        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
14956        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
14957        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
14958        settle().await;
14959        dissolve_community(&transport, &refounded).await.expect("dissolve");
14960        log("[dissolve] community sealed".to_string());
14961        log("===== LIVE e2e PASS =====".to_string());
14962    }
14963
14964    #[tokio::test]
14965    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
14966        // The tombstone rides its own public plane, watched live — an OFFLINE
14967        // member's catch-up must fetch it too, or they follow (and post into) a
14968        // grave forever.
14969        let (bed, owner, member) = TestBed::new();
14970        bed.swap_to(&owner);
14971        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
14972        let general = community.channels[0].id;
14973        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
14974
14975        bed.swap_to(&member);
14976        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
14977        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
14978
14979        // The owner dissolves while the member sleeps.
14980        bed.swap_to(&owner);
14981        dissolve_community(&bed.relay, &community).await.unwrap();
14982
14983        // The member's catch-up learns of the death, seals, and refuses to post.
14984        bed.swap_to(&member);
14985        let session = crate::db::current_session();
14986        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14987        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
14988        assert!(!follow.self_removed && follow.updated.is_none());
14989        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14990        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
14991        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
14992        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
14993        // Subsequent follows take the local fast path — still dissolved, no churn.
14994        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14995        assert!(again.dissolved && again.updated.is_none());
14996    }
14997
14998    #[tokio::test]
14999    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
15000        // Scale stress: MANY private channels, each rotated on every Refounding.
15001        // A member offline across two refoundings must converge on all of them
15002        // (the per-channel rotation fan in refound + the follow's channel×root×step
15003        // loops stay bounded) with every channel's history readable.
15004        const PRIV_CHANNELS: usize = 6;
15005        let (bed, owner, member) = TestBed::new();
15006        bed.swap_to(&owner);
15007        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
15008        let mut priv_ids = Vec::new();
15009        for i in 0..PRIV_CHANNELS {
15010            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
15011            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15012            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
15013            priv_ids.push(id);
15014        }
15015        // Private channels are readable only by granted role-holders (CORD-03).
15016        for id in &priv_ids {
15017            grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
15018        }
15019        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15020
15021        // Member joins at epoch 0 with all channel keys, then goes offline.
15022        bed.swap_to(&member);
15023        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15024        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
15025
15026        // Two refoundings (each rotates the base + every private channel).
15027        bed.swap_to(&owner);
15028        for epoch in 1..=2u64 {
15029            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15030            assert_eq!(community.root_epoch, Epoch(epoch));
15031            for id in &priv_ids {
15032                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
15033            }
15034        }
15035
15036        // Member returns: bounded follow to quiescence.
15037        bed.swap_to(&member);
15038        let session = crate::db::current_session();
15039        let mut passes = 0;
15040        loop {
15041            passes += 1;
15042            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
15043            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15044            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15045            assert!(!rk.self_removed);
15046            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15047            let ctl = follow_control(&bed.relay, &cur).await.unwrap().updated;
15048            if rk.updated.is_none() && ctl.is_none() {
15049                break;
15050            }
15051        }
15052        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15053        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
15054        // Every private channel converged to the owner's current key + reads all epochs.
15055        for id in &priv_ids {
15056            let mine = caught_up.channel(id).expect("channel survived");
15057            let theirs = community.channel(id).unwrap();
15058            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
15059            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
15060            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
15061            // Opening reads the live planes; the rotated-past ones are the
15062            // back-paging cursor's job. Both halves asserted — the point is that
15063            // every epoch stays REACHABLE, not that opening drags it all in.
15064            let open = texts_in(&bed.relay, &caught_up, id).await;
15065            assert!(open.iter().any(|t| t.contains("epoch2")), "channel {id_hex} opens onto its live epoch");
15066            let paged = all_texts_in(&bed.relay, &caught_up, id).await;
15067            assert!(paged.iter().any(|t| t.contains("epoch0")), "channel {id_hex} pages back to epoch-0 history");
15068            for epoch in 1..=2u64 {
15069                assert!(paged.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} pages back to epoch-{epoch} history");
15070            }
15071        }
15072    }
15073
15074    #[tokio::test]
15075    async fn a_backlog_deeper_than_max_steps_still_converges() {
15076        // MAX_STEPS bounds ONE walk, not the catch-up. A member who slept through more
15077        // rotations than the bound must still land on the head — silently stopping at
15078        // the bound leaves them on a dead epoch reading nothing, which is
15079        // indistinguishable from a quiet community.
15080        let (bed, owner, member) = TestBed::new();
15081        bed.swap_to(&owner);
15082        let mut community = create_community(&bed.relay, "DeepSleeper", bed.relays.clone(), None).await.unwrap();
15083        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15084
15085        bed.swap_to(&member);
15086        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15087        assert_eq!(joined.root_epoch, Epoch(0));
15088
15089        // Past the bound, deliberately.
15090        bed.swap_to(&owner);
15091        let depth = MAX_WALK_STEPS + 3;
15092        for _ in 0..depth {
15093            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15094        }
15095        assert_eq!(community.root_epoch, Epoch(depth as u64));
15096
15097        // The member follows repeatedly, exactly as a client does on each sync. Every
15098        // pass must make forward progress and none may lose ground.
15099        bed.swap_to(&member);
15100        let mut last = 0u64;
15101        for pass in 1..=8 {
15102            // Reload from the DB each pass, exactly as a client does: this is what
15103            // proves every hop was CHECKPOINTED, not merely held in the walk's memory.
15104            let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15105            let session = crate::db::current_session();
15106            let _ = follow_rekeys(&bed.relay, &view, &session).await.unwrap();
15107            let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15108            assert!(
15109                view.root_epoch.0 >= last,
15110                "pass {pass} lost ground: {} -> {}",
15111                last, view.root_epoch.0
15112            );
15113            last = view.root_epoch.0;
15114            if last == depth as u64 {
15115                break;
15116            }
15117        }
15118        let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15119        assert_eq!(
15120            view.root_epoch, Epoch(depth as u64),
15121            "a backlog deeper than one walk's step bound must still converge across passes",
15122        );
15123    }
15124
15125    #[tokio::test]
15126    async fn an_offline_member_catches_up_across_three_refoundings() {
15127        // The deep offline-online scenario: a member sleeps through THREE
15128        // Refoundings, per-refound private-channel rotations, a mid-life private
15129        // channel CREATED while they slept, a public channel, a rename, and a
15130        // ban — then returns and converges by follow alone (no rejoin).
15131        use nostr_sdk::prelude::ToBech32;
15132        let (bed, owner, member) = TestBed::new();
15133        bed.swap_to(&owner);
15134        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
15135        let general = community.channels[0].id;
15136        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
15137        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15138        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
15139        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
15140        // Private channels are readable only by granted role-holders (CORD-03).
15141        grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
15142        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15143
15144        // Member joins at epoch 0, then goes OFFLINE.
15145        bed.swap_to(&member);
15146        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15147        assert_eq!(member_view.root_epoch, Epoch(0));
15148
15149        // While they sleep, the owner reshapes everything across three epochs.
15150        bed.swap_to(&owner);
15151        let stranger = Keys::generate();
15152        for epoch in 1..=3u64 {
15153            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15154            assert_eq!(community.root_epoch, Epoch(epoch));
15155            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
15156            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
15157        }
15158        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
15159        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15160        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
15161        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15162        // The sleeper is on this channel's access list, so the refoundings that
15163        // follow deliver its key to them (CORD-03).
15164        grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
15165        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15166        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
15167        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
15168        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
15169        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
15170
15171        // The member RETURNS: rekey+control follow to quiescence (the worker's
15172        // loop, driven explicitly). Bounded — convergence must be fast.
15173        bed.swap_to(&member);
15174        let session = crate::db::current_session();
15175        let mut passes = 0;
15176        loop {
15177            passes += 1;
15178            assert!(passes <= 6, "catch-up must converge, not churn");
15179            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15180            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15181            assert!(!rekeyed.self_removed, "the member was never removed");
15182            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15183            let controlled = follow_control(&bed.relay, &cur).await.unwrap().updated;
15184            if rekeyed.updated.is_none() && controlled.is_none() {
15185                break;
15186            }
15187        }
15188        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15189
15190        // Base + name converged.
15191        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
15192        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
15193        assert_eq!(caught_up.name, "Sleeper Reborn");
15194        // Channels: renamed set incl. the mid-sleep public + private ones.
15195        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
15196        let m = caught_up.channel(&mods).expect("mods survived");
15197        let owner_mods = community.channel(&mods).unwrap();
15198        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
15199        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
15200        let v = caught_up.channel(&vault).expect("vault folded in");
15201        // The sleeper is on vault's access list, but it was created AFTER the last
15202        // refounding — no rotation followed the grant, so no blob was ever
15203        // addressed to them. They hold the channel keyless until the grant's own
15204        // key vend lands (CORD-05 §6), which is what a rekey-only walk cannot do.
15205        assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
15206        // Banlist survived the compactions.
15207        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
15208        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15209        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
15210        // Opening reads the live planes only; paging back reaches EVERY epoch
15211        // (public via the base-root archive, private via the per-channel archive
15212        // built during the walk). A sleeper's deep history stays reachable — it
15213        // just arrives when they scroll for it rather than on open.
15214        let gen_open = texts_in(&bed.relay, &caught_up, &general).await;
15215        assert!(gen_open.contains(&"epoch3: general news".to_string()), "general opens onto its live epoch: {gen_open:?}");
15216        let gen_texts = all_texts_in(&bed.relay, &caught_up, &general).await;
15217        for epoch in 0..=3u64 {
15218            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
15219            assert!(gen_texts.contains(&needle), "general pages back to epoch {epoch}: {gen_texts:?}");
15220        }
15221        let mods_open = texts_in(&bed.relay, &caught_up, &mods).await;
15222        assert!(mods_open.contains(&"epoch3: mods word".to_string()), "mods opens onto its live epoch: {mods_open:?}");
15223        let mods_texts = all_texts_in(&bed.relay, &caught_up, &mods).await;
15224        for epoch in 0..=3u64 {
15225            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
15226            assert!(mods_texts.contains(&needle), "private pages back to epoch {epoch}: {mods_texts:?}");
15227        }
15228        // Keyless (above) means unreadable — a rekey walk cannot substitute for the
15229        // key vend that a grant carries.
15230        assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
15231        // And the member can still speak.
15232        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
15233        bed.swap_to(&owner);
15234        assert!(
15235            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
15236            "the caught-up member converses at the new epoch ({})",
15237            member.keys.public_key().to_bech32().unwrap()
15238        );
15239    }
15240
15241    /// Seal `n` messages onto a community's #general, one per second starting at
15242    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
15243    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
15244        let general = community.channels[0].id;
15245        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15246        for i in 0..n {
15247            let at = base_secs + i as u64;
15248            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
15249            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
15250            relay.publish(&wrap, &community.relays).await.unwrap();
15251        }
15252    }
15253
15254    #[tokio::test]
15255    async fn the_history_walk_pages_past_a_multi_page_burst() {
15256        // A bot offline through 120 messages must catch ALL of them, not the
15257        // newest page — the v1 sync-gap class, closed by until-paging.
15258        let (_tmp, _guard, owner) = init_test_db();
15259        let relay = MemoryRelay::new();
15260        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
15261        let general = community.channels[0].id;
15262        flood_general(&relay, &community, &owner, 120, 10_000).await;
15263
15264        let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15265        assert_eq!(all.len(), 120, "the walk pages the whole burst");
15266        // Oldest→newest, no duplicates.
15267        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15268        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15269        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
15270        let unique: std::collections::HashSet<&String> = contents.iter().collect();
15271        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
15272
15273        // The single-page fetch stays a single page.
15274        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
15275        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
15276        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15277    }
15278
15279    #[tokio::test]
15280    async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
15281        // The back-paging cursor: a walk that starts at an explicit `until`
15282        // returns only what lies at-or-before it, oldest→newest — the relay-side
15283        // half of the SDK's walk-until-dry loop.
15284        let (_tmp, _guard, owner) = init_test_db();
15285        let relay = MemoryRelay::new();
15286        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
15287        let general = community.channels[0].id;
15288        flood_general(&relay, &community, &owner, 120, 10_000).await;
15289
15290        let older = fetch_channel_history(
15291            &relay, &community, &general, 50, 8, None, Some(10_059),
15292            crate::community::transport::Evidence::Quorum, |_| true,
15293        )
15294        .await
15295        .unwrap();
15296        let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15297        assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
15298        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15299        assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
15300    }
15301
15302    #[tokio::test]
15303    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
15304        let (_tmp, _guard, owner) = init_test_db();
15305        let relay = MemoryRelay::new();
15306        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
15307        let general = community.channels[0].id;
15308        flood_general(&relay, &community, &owner, 120, 10_000).await;
15309
15310        // The caller says "I hold everything" after the first page — no deeper fetch.
15311        let mut pages = 0usize;
15312        let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
15313            pages += 1;
15314            false
15315        })
15316        .await
15317        .unwrap();
15318        assert_eq!(pages, 1, "the early stop is consulted once");
15319        assert_eq!(got.len(), 50, "only the newest page is fetched");
15320        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15321    }
15322
15323    #[tokio::test]
15324    async fn a_same_second_history_wall_terminates_instead_of_looping() {
15325        // 60 messages in ONE second with a 25-wrap page: a second-granular
15326        // `until` can never page past the wall — the walk must step over it
15327        // (bounded loss, logged) rather than spin.
15328        let (_tmp, _guard, owner) = init_test_db();
15329        let relay = MemoryRelay::new();
15330        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
15331        let general = community.channels[0].id;
15332        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15333        for i in 0..60usize {
15334            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
15335            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
15336            relay.publish(&wrap, &community.relays).await.unwrap();
15337        }
15338        let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15339        assert!(got.len() >= 25, "at least the relay page is read");
15340        assert!(got.len() <= 60, "sane bound");
15341        // Termination is the assertion: reaching here means the wall didn't loop.
15342    }
15343
15344    #[tokio::test]
15345    async fn a_grant_revoke_survives_a_withholding_relay() {
15346        // Floor persistence on the delegation plane: after the owner revokes an admin,
15347        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
15348        let (_tmp, _guard, owner) = init_test_db();
15349        let relay = MemoryRelay::new();
15350        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
15351        let admin = Keys::generate();
15352        let rid = "d4".repeat(32);
15353        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
15354        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
15355        follow_control(&relay, &community).await.unwrap().updated; // seed floors incl. the grant at v1
15356        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
15357        follow_control(&relay, &community).await.unwrap().updated;
15358
15359        // A stale relay serves only the grant prefix (v1, the live grant).
15360        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
15361        let mut stale = community.clone();
15362        stale.relays = vec!["wss://stale".into()];
15363        let floors = load_floors(&community);
15364        let editions = fetch_control(&relay, &stale).await;
15365        let authority = fold_authority(&stale, &editions, &floors);
15366        assert!(
15367            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
15368            "the persisted grant floor refuses the rolled-back (re-granted) view"
15369        );
15370    }
15371
15372    /// Load the current-epoch floors for a community (test mirror of follow_control).
15373    fn load_floors(community: &CommunityV2) -> Floors {
15374        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15375        crate::db::community::get_all_edition_heads_full(&cid_hex)
15376            .unwrap_or_default()
15377            .into_iter()
15378            .filter(|(_, f)| f.0 == community.root_epoch.0)
15379            .map(|(e, f)| (e, (f.1, f.2, f.3)))
15380            .collect()
15381    }
15382
15383    /// Fetch + open every control edition at a community's control plane (test helper).
15384    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
15385        let group = control::ControlPlane::of(&community).write_group().unwrap();
15386        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
15387        relay
15388            .fetch(&q, &community.relays)
15389            .await
15390            .unwrap_or_default()
15391            .iter()
15392            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
15393            .collect()
15394    }
15395
15396    #[tokio::test]
15397    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
15398        let (_tmp, _guard, _owner) = init_test_db();
15399        let relay = MemoryRelay::new();
15400        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
15401        // Only the genesis editions exist; folding them reproduces the held view.
15402        assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
15403    }
15404
15405    #[tokio::test]
15406    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
15407        let (_tmp, _guard, owner) = init_test_db();
15408        let relay = MemoryRelay::new();
15409        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
15410        let new_id = ChannelId([0x5a; 32]);
15411        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
15412
15413        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("a new channel changed the view");
15414        assert_eq!(updated.channels.len(), 2);
15415        let added = updated.channel(&new_id).expect("the new channel folded in");
15416        assert_eq!(added.name, "announcements");
15417        assert!(!added.private);
15418        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
15419
15420        // The new channel is now in the realtime author-set (it would be subscribed).
15421        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
15422        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
15423        assert!(authors.contains(&addr), "the added channel joins the live subscription");
15424
15425        // Persisted: a reload sees it too.
15426        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15427        assert!(reloaded.channel(&new_id).is_some());
15428    }
15429
15430    #[tokio::test]
15431    async fn follow_control_renames_the_community_and_an_existing_channel() {
15432        let (_tmp, _guard, owner) = init_test_db();
15433        let relay = MemoryRelay::new();
15434        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
15435        let general = community.channels[0].id;
15436        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
15437        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
15438        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
15439
15440        let updated = follow_control(&relay, &community).await.unwrap().updated.unwrap();
15441        assert_eq!(updated.name, "New Name");
15442        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
15443        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
15444    }
15445
15446    #[tokio::test]
15447    async fn follow_control_deletes_a_channel() {
15448        let (_tmp, _guard, owner) = init_test_db();
15449        let relay = MemoryRelay::new();
15450        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
15451        let extra = ChannelId([0x77; 32]);
15452
15453        // The channel is first added and folded into the held view.
15454        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
15455        let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
15456        assert!(with_extra.channel(&extra).is_some());
15457
15458        // Then it's tombstoned — the delete (higher version) folds the held one back out.
15459        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
15460        let updated = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
15461        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
15462        assert_eq!(updated.channels.len(), 1, "only #general remains");
15463    }
15464
15465    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
15466    /// community's control plane onto a second relay URL — the withholding-relay
15467    /// simulation: everything it serves is genuinely owner-signed, just stale.
15468    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
15469        let group = control::ControlPlane::of(&community).write_group().unwrap();
15470        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
15471        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
15472        for w in &wraps {
15473            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
15474                if ed.version <= max_version {
15475                    relay.inject(w, &[stale_relay.to_string()]);
15476                }
15477            }
15478        }
15479    }
15480
15481    #[tokio::test]
15482    async fn a_withholding_relay_cannot_roll_back_a_rename() {
15483        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
15484        // only the (owner-signed) v1 genesis must not revert the held name.
15485        let (_tmp, _guard, owner) = init_test_db();
15486        let relay = MemoryRelay::new();
15487        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
15488        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
15489
15490        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("rename adopted");
15491        assert_eq!(updated.name, "Renamed");
15492
15493        // The stale relay holds only the genesis prefix; point the follow at it.
15494        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
15495        let mut stale_view = updated.clone();
15496        stale_view.relays = vec!["wss://stale".into()];
15497        assert!(
15498            follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
15499            "a stale-only relay must not change the held view"
15500        );
15501        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15502        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
15503    }
15504
15505    #[tokio::test]
15506    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
15507        let (_tmp, _guard, owner) = init_test_db();
15508        let relay = MemoryRelay::new();
15509        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
15510        let extra = ChannelId([0x44; 32]);
15511
15512        // A same-content metadata edit: no visible change (None), but the floor must
15513        // still advance to v2 (so the genesis metadata can't re-present below).
15514        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
15515        assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
15516
15517        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
15518        let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
15519        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
15520        let pruned = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
15521        assert!(pruned.channel(&extra).is_none());
15522
15523        // The stale relay serves the add (v1) but withholds the delete (v2).
15524        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
15525        let mut stale_view = pruned.clone();
15526        stale_view.relays = vec!["wss://stale".into()];
15527        assert!(
15528            follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
15529            "the withheld delete must not resurrect the channel"
15530        );
15531        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15532        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
15533    }
15534
15535    #[tokio::test]
15536    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
15537        // The Armada-convergence carve-out: a Refounding compacts the chain and
15538        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
15539        // floor must not block it — epoch-filtering makes the entity bootstrap.
15540        let (_tmp, _guard, owner) = init_test_db();
15541        let relay = MemoryRelay::new();
15542        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
15543        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
15544        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("edit adopted");
15545        assert_eq!(updated.name, "Edited");
15546
15547        // Refounding lands (epoch bump saved by the rekey path); the compacted head
15548        // arrives DETACHED (high version, no prev) on the new epoch's plane.
15549        let refounded = rotate_view(&updated, updated.community_root, 1);
15550        crate::db::community::save_community_v2(&refounded).unwrap();
15551        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
15552
15553        let adopted = follow_control(&relay, &refounded).await.unwrap().updated.expect("compacted head adopted");
15554        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
15555        // The persisted floor is stamped with the epoch the FOLD ran under.
15556        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15557        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
15558        assert!(
15559            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
15560            "the adopted head carries the fold's epoch + version"
15561        );
15562    }
15563
15564    #[tokio::test]
15565    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
15566        // Two owner-signed editions at the SAME version (publish retry / two owner
15567        // devices): every client must land on the lower-inner-id winner. A hash-strict
15568        // floor would wedge here forever while Armada converges — the floor must
15569        // CONVERGE instead (the v1 decide() rule).
15570        let (_tmp, _guard, owner) = init_test_db();
15571        let relay = MemoryRelay::new();
15572        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
15573        let group = control::ControlPlane::of(&community).write_group().unwrap();
15574        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15575
15576        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
15577        let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
15578        assert_eq!(ours.name, "Ours");
15579
15580        // Our committed v2 edition's tiebreak id.
15581        let our_inner = {
15582            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
15583            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
15584            wraps
15585                .iter()
15586                .find_map(|w| {
15587                    control::open_control_edition(w, &group)
15588                        .ok()
15589                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
15590                        .map(|(ed, _)| ed.inner_id)
15591                })
15592                .unwrap()
15593        };
15594
15595        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
15596        // authored timestamp until its inner id is lower).
15597        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
15598        let content = serde_json::to_string(&meta).unwrap();
15599        let mut ts = 2_000u64;
15600        let fork_wrap = loop {
15601            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
15602            let inner = rumor.id.unwrap().to_bytes();
15603            if inner < our_inner {
15604                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
15605            }
15606            ts += 1;
15607        };
15608        relay.publish(&fork_wrap, &community.relays).await.unwrap();
15609
15610        let converged = follow_control(&relay, &ours).await.unwrap().updated.expect("fork winner adopted");
15611        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
15612        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15613        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
15614        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
15615    }
15616
15617    #[tokio::test]
15618    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
15619        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
15620        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
15621        // while the detached v4 waits. When v3 lands, the chain heals to v4.
15622        let (_tmp, _guard, owner) = init_test_db();
15623        let relay = MemoryRelay::new();
15624        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
15625        let group = control::ControlPlane::of(&community).write_group().unwrap();
15626
15627        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
15628        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15629
15630        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
15631        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
15632        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
15633        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
15634        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
15635        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
15636        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
15637        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
15638        relay.publish(&w4, &community.relays).await.unwrap();
15639
15640        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("the verified prefix applies");
15641        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
15642
15643        relay.publish(&w3, &community.relays).await.unwrap();
15644        let healed = follow_control(&relay, &updated).await.unwrap().updated.expect("the chain heals");
15645        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
15646    }
15647
15648    #[tokio::test]
15649    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
15650        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
15651        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
15652        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
15653        let (_tmp, _guard, owner) = init_test_db();
15654        let relay = MemoryRelay::new();
15655        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
15656        let group = control::ControlPlane::of(&community).write_group().unwrap();
15657
15658        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
15659        let base = follow_control(&relay, &community).await.unwrap().updated.expect("floor at v2");
15660        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
15661        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15662
15663        // Rogue flood occupying the newest window (sealed to the control plane, but
15664        // non-owner — the authority gate drops them; they only crowd the page).
15665        let rogue = Keys::generate();
15666        for i in 0..(FOLLOW_PAGE as u64 - 1) {
15667            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
15668            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
15669            relay.publish(&w, &community.relays).await.unwrap();
15670        }
15671        // v4 chained to the real v3 (crafted directly: the flood also blinds the
15672        // helper's own newest-window head lookup), timestamped newest of all.
15673        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
15674        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
15675        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
15676        relay.publish(&w4, &community.relays).await.unwrap();
15677
15678        let healed = follow_control(&relay, &base).await.unwrap().updated.expect("paging recovered the chain");
15679        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
15680    }
15681
15682    #[tokio::test]
15683    async fn a_follow_after_delete_does_not_resurrect_the_community() {
15684        // A leave/delete racing an in-flight follow: the follow must not re-insert
15685        // the community row or floor rows past delete_community's wipe.
15686        let (_tmp, _guard, owner) = init_test_db();
15687        let relay = MemoryRelay::new();
15688        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
15689        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
15690        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15691        crate::db::community::delete_community(&cid_hex).unwrap();
15692
15693        assert!(
15694            follow_control(&relay, &community).await.unwrap().updated.is_none(),
15695            "a follow racing a delete is a no-op"
15696        );
15697        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
15698        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
15699    }
15700
15701    #[tokio::test]
15702    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
15703        // The rekey sibling of the follow_control guard: an owner rotation adopted
15704        // mid-race must not upsert the community row back after a leave/delete.
15705        let (_tmp, _guard, owner) = init_test_db();
15706        let relay = MemoryRelay::new();
15707        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
15708        let new_root = [0xB2; 32];
15709        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
15710        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15711        crate::db::community::delete_community(&cid_hex).unwrap();
15712
15713        let session = crate::db::current_session();
15714        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
15715        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
15716        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
15717    }
15718
15719    #[tokio::test]
15720    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
15721        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
15722        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
15723        // would take v1 and SEED the joiner's floor there — pinning them below the
15724        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
15725        let (bed, owner, member) = TestBed::new();
15726        bed.swap_to(&owner);
15727        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
15728        let group = control::ControlPlane::of(&community).write_group().unwrap();
15729        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
15730
15731        // v2 is crafted but NEVER published; v3 chains to it and is published.
15732        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
15733        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
15734        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
15735        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
15736        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
15737        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);
15738        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
15739        bed.relay.publish(&w3, &community.relays).await.unwrap();
15740
15741        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
15742        let bundle_json = serde_json::to_string(&bundle).unwrap();
15743        bed.swap_to(&member);
15744        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15745        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
15746        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
15747        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
15748        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
15749    }
15750
15751    #[tokio::test]
15752    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
15753        // The refusal half of fork convergence: a relay withholding OUR committed
15754        // floor edition while serving only a same-version fork with a HIGHER inner
15755        // id must be treated as withholding — held state and floor unchanged.
15756        let (_tmp, _guard, owner) = init_test_db();
15757        let relay = MemoryRelay::new();
15758        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
15759        let group = control::ControlPlane::of(&community).write_group().unwrap();
15760        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
15761
15762        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
15763        let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
15764        assert_eq!(ours.name, "Ours");
15765        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15766        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
15767        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
15768
15769        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
15770        // with the genesis but WITHOUT our v2 — from a withholding relay.
15771        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
15772        let content = serde_json::to_string(&meta).unwrap();
15773        let mut ts = 5_000u64;
15774        let fork_wrap = loop {
15775            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
15776            if rumor.id.unwrap().to_bytes() > our_inner {
15777                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
15778            }
15779            ts += 1;
15780        };
15781        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
15782        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
15783        let mut stale_view = ours.clone();
15784        stale_view.relays = vec!["wss://stale".into()];
15785
15786        assert!(
15787            follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
15788            "a losing fork served without our floor edition changes nothing"
15789        );
15790        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
15791        assert_eq!(held_after, held_before, "the floor row is untouched");
15792        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15793        assert_eq!(held.name, "Ours", "the held state is untouched");
15794    }
15795
15796    #[tokio::test]
15797    async fn follow_control_ignores_a_non_owner_edition() {
15798        // A member holds the community_root, so they CAN seal a control edition —
15799        // but they aren't the owner, so the authority gate drops it (first cut:
15800        // owner-only). The rogue channel must never appear.
15801        let (_tmp, _guard, _owner) = init_test_db();
15802        let relay = MemoryRelay::new();
15803        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
15804        let rogue = Keys::generate();
15805        let rogue_id = ChannelId([0x99; 32]);
15806        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
15807
15808        assert!(
15809            follow_control(&relay, &community).await.unwrap().updated.is_none(),
15810            "a non-owner control edition is not folded"
15811        );
15812    }
15813
15814    #[tokio::test]
15815    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
15816        // A Private channel's key rides the rekey plane, not the control edition —
15817        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
15818        // every read/send path refuses it until the key lands (never the root plane).
15819        let (_tmp, _guard, owner) = init_test_db();
15820        let relay = MemoryRelay::new();
15821        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
15822        let priv_id = ChannelId([0x33; 32]);
15823        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
15824
15825        let updated = follow_control(&relay, &community)
15826            .await
15827            .unwrap()
15828            .updated
15829            .expect("the keyless record is a change");
15830        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
15831        assert!(ch.private && ch.key.is_none(), "recorded keyless");
15832        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
15833        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
15834        assert!(
15835            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
15836            "a keyless fetch returns empty (and never queries the root plane)"
15837        );
15838        assert!(
15839            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
15840            "a keyless send refuses"
15841        );
15842        // The keyless record round-trips (the stored placeholder never surfaces
15843        // as a real key).
15844        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
15845        let rch = reloaded.channel(&priv_id).unwrap();
15846        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
15847        // And a bundle minted while keyless never carries the placeholder — a
15848        // MEMBER audience, so it's the keyless filter proving it (the link
15849        // filter would drop the channel for the weaker reason).
15850        let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
15851        assert!(
15852            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
15853            "an ungrantable keyless channel stays out of invite bundles"
15854        );
15855    }
15856
15857    #[tokio::test]
15858    async fn a_link_bundle_never_carries_a_private_channel_key() {
15859        // A link's audience holds no Role by construction (CORD-05), so a HELD
15860        // private key must never ride a link bundle — anyone with the URL would
15861        // get the channel. A member bundle carries it; a link bundle only the
15862        // public channels.
15863        let (_tmp, _guard, _owner) = init_test_db();
15864        let relay = MemoryRelay::new();
15865        let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
15866        create_private_channel(&relay, &community, "mods").await.unwrap();
15867        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15868        let priv_hex = held
15869            .channels
15870            .iter()
15871            .find(|c| c.private)
15872            .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
15873            .expect("the private channel is held WITH its key");
15874
15875        let link = bundle_of(&held, BundleAudience::Link, None, None, None);
15876        assert!(
15877            !link.channels.iter().any(|c| c.id == priv_hex),
15878            "a held private key must never ride a link bundle"
15879        );
15880        assert!(
15881            link.channels.iter().any(|c| c.id != priv_hex),
15882            "the public channels still ride it"
15883        );
15884
15885        // A member bundle grants it only to the ENTITLED. An unrelated npub holds
15886        // no scoped role, so it gets nothing; the creator (granted the companion
15887        // access role at create) gets the key.
15888        let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
15889        assert!(
15890            !stranger.channels.iter().any(|c| c.id == priv_hex),
15891            "an unentitled member gets no private key"
15892        );
15893        let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
15894        assert!(
15895            mine.channels.iter().any(|c| c.id == priv_hex),
15896            "the creator is entitled via the companion access role"
15897        );
15898    }
15899
15900    /// Mint a server-scope role at `position` carrying `permissions`, grant it to
15901    /// `member`, and return its id. Used to build a RANKED roster (the owner-only
15902    /// tests can't exercise outranking, since the owner outranks everyone).
15903    async fn seat_ranked_role(
15904        relay: &MemoryRelay,
15905        community: &CommunityV2,
15906        member: &PublicKey,
15907        name: &str,
15908        position: u32,
15909        permissions: crate::community::roles::Permissions,
15910    ) -> String {
15911        let role_id = crate::crypto::sha256_hex(format!("test/role/{name}/{position}").as_bytes());
15912        let role = crate::community::roles::Role {
15913            role_id: role_id.clone(),
15914            name: name.to_string(),
15915            position,
15916            permissions,
15917            scope: crate::community::roles::RoleScope::Server,
15918            color: 0,
15919        };
15920        set_role(relay, community, &role).await.unwrap();
15921        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15922        let mut ids: Vec<String> = {
15923            let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15924            let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15925            roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect()
15926        };
15927        if !ids.contains(&role_id) {
15928            ids.push(role_id.clone());
15929        }
15930        grant_roles(relay, &held, member, ids).await.unwrap();
15931        role_id
15932    }
15933
15934    /// Become `who` without touching the account DB — the acting identity only.
15935    /// Enough to exercise an offer-side authority gate from a non-owner's seat.
15936    fn act_as(who: &Keys) {
15937        crate::state::MY_SECRET_KEY.store_from_keys(who, &[]);
15938        crate::state::set_my_public_key(who.public_key());
15939    }
15940
15941    #[tokio::test]
15942    async fn a_moderator_cannot_revoke_channel_access_from_a_superior_and_the_channel_never_rotates() {
15943        // CORD-04 §2: the fold drops a Grant aimed at a peer or superior, but the
15944        // channel rotation is gated on the citation alone — so without an
15945        // offer-side rank check the Grant dies and the target is severed anyway.
15946        // The epoch assertion is the point: refusing the publish is worthless if
15947        // the rekey still runs.
15948        let (_tmp, _guard, owner) = init_test_db();
15949        let relay = MemoryRelay::new();
15950        let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
15951        let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
15952
15953        let superior = Keys::generate();
15954        let moderator = Keys::generate();
15955        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15956        grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
15957        seat_ranked_role(
15958            &relay,
15959            &community,
15960            &moderator.public_key(),
15961            "Moderator",
15962            5,
15963            crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
15964        )
15965        .await;
15966
15967        // The superior legitimately holds the channel (granted by the owner).
15968        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15969        grant_channel_access(&relay, &held, &vault, &superior.public_key()).await.unwrap();
15970        let before = crate::db::community::load_community_v2(community.id())
15971            .unwrap()
15972            .unwrap()
15973            .channel(&vault)
15974            .unwrap()
15975            .epoch;
15976
15977        act_as(&moderator);
15978        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15979        let err = revoke_channel_access(&relay, &held, &vault, &superior.public_key())
15980            .await
15981            .expect_err("a moderator must not act on an admin");
15982        assert!(err.contains("outrank"), "refused for RANK, not some incidental reason: {err}");
15983
15984        let after = crate::db::community::load_community_v2(community.id())
15985            .unwrap()
15986            .unwrap()
15987            .channel(&vault)
15988            .unwrap()
15989            .epoch;
15990        assert_eq!(after, before, "the refusal must also stop the rotation — the rekey IS the severance");
15991        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15992        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
15993        let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
15994        let owner_hex = owner.public_key().to_hex();
15995        assert!(
15996            roster.is_entitled(Some(&owner_hex), &superior.public_key().to_hex(), &chan_hex, &[], &[]),
15997            "and the superior keeps their access role"
15998        );
15999    }
16000
16001    #[tokio::test]
16002    async fn a_moderator_cannot_grant_channel_access_to_a_superior_but_the_owner_stays_grantable() {
16003        // The grant direction of the same gate. The owner is never a valid rank
16004        // target, yet IS a legitimate recipient of a key an admin minted — the
16005        // rank check must not swallow that case.
16006        let (_tmp, _guard, owner) = init_test_db();
16007        let relay = MemoryRelay::new();
16008        let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
16009        let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
16010
16011        let superior = Keys::generate();
16012        let moderator = Keys::generate();
16013        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16014        grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
16015        let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
16016        // Scoped access so the moderator holds the channel key itself, else the
16017        // refusal could come from "we hold no key" and prove nothing about rank.
16018        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16019        grant_channel_access(&relay, &held, &vault, &moderator.public_key()).await.unwrap();
16020        seat_ranked_role(
16021            &relay,
16022            &community,
16023            &moderator.public_key(),
16024            "Moderator",
16025            5,
16026            crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
16027        )
16028        .await;
16029
16030        act_as(&moderator);
16031        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16032        let err = grant_channel_access(&relay, &held, &vault, &superior.public_key())
16033            .await
16034            .expect_err("a moderator must not grant against an admin");
16035        assert!(err.contains("outrank"), "refused for RANK: {err}");
16036
16037        // Same seat, same channel, owner as the target: allowed.
16038        grant_channel_access(&relay, &held, &vault, &owner.public_key())
16039            .await
16040            .expect("the owner is not a rank target, and may still be handed a key");
16041        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16042        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16043        let owner_hex = owner.public_key().to_hex();
16044        assert!(
16045            roster.is_entitled(Some(&owner_hex), &owner_hex, &chan_hex, &[], &[]),
16046            "the owner's access role landed"
16047        );
16048    }
16049
16050    #[tokio::test]
16051    async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
16052        // CORD-03/04: the roles scoped to a channel ARE its access list. Proven
16053        // against a NON-owner so the owner-is-always-entitled rule can't carry it.
16054        let (_tmp, _guard, _owner) = init_test_db();
16055        let relay = MemoryRelay::new();
16056        let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
16057        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16058        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16059        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16060
16061        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16062        let access = roster.channel_roles(&chan_hex);
16063        assert_eq!(access.len(), 1, "the channel minted exactly one access role");
16064        assert!(
16065            access[0].permissions == crate::community::roles::Permissions::empty(),
16066            "the access role confers READ access (key possession), never authority"
16067        );
16068        assert_eq!(access[0].name, "mods", "named for its channel");
16069
16070        // A stranger holds no scoped role: unentitled, and no key rides their bundle.
16071        let stranger = Keys::generate().public_key();
16072        let owner_hex = community.owner().unwrap().to_hex();
16073        assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
16074
16075        // Granting the access role entitles them; revoking un-entitles them. Both
16076        // proven through the roster, which is what routes keys.
16077        let role_id = access[0].role_id.clone();
16078        assert!(
16079            roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
16080            "the grant overlay entitles before the fold catches up"
16081        );
16082
16083        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16084        grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16085        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16086        assert!(
16087            after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16088            "the grant landed in the local roster (the fold runs later)"
16089        );
16090        let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
16091        assert!(
16092            vend.channels.iter().any(|c| c.id == chan_hex),
16093            "a now-entitled member's bundle carries the channel key"
16094        );
16095
16096        revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16097        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16098        assert!(
16099            !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16100            "the revoke dropped the access role"
16101        );
16102        let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16103        assert_eq!(
16104            rotated.channel(&priv_id).unwrap().epoch,
16105            Epoch(2),
16106            "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
16107        );
16108
16109        // The access summary a bot reads back: roles, holders, and key state.
16110        let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
16111        assert_eq!(access["private"], true);
16112        assert_eq!(access["readable"], true, "we minted it, so we hold its key");
16113        assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
16114        let holders = access["members"].as_array().unwrap();
16115        let me_npub = {
16116            use nostr_sdk::prelude::ToBech32;
16117            me_pk().unwrap().to_bech32().unwrap()
16118        };
16119        assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
16120        assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
16121    }
16122
16123    #[tokio::test]
16124    async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
16125        // CORD-06 §1 per channel: a Public-community ban skips the Refounding
16126        // (CORD-05 §5), so without this rotation the banned member keeps each
16127        // held channel key and reads on forever. Only channels they could
16128        // actually reach rotate — the rest keep their epoch.
16129        let (_tmp, _guard, _owner) = init_test_db();
16130        let relay = MemoryRelay::new();
16131        let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
16132        let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
16133        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16134        let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
16135        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16136
16137        let spammer = Keys::generate().public_key();
16138        let bystander = Keys::generate().public_key();
16139        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16140        grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
16141        grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
16142
16143        // The ban composition's capture-then-strip: entitlement must be judged
16144        // from the PRE-strip roles, whether or not the strip has folded.
16145        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16146        let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
16147        assert!(!stripped.is_empty(), "the grant landed before the strip");
16148        grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
16149
16150        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16151        let rotated = sever_banned_private_reads(&relay, &held, &[(spammer, stripped)]).await.unwrap();
16152        assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
16153        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16154        assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
16155        assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
16156
16157        // A member who never had reach rotates nothing.
16158        let stranger = Keys::generate().public_key();
16159        let n = sever_banned_private_reads(&relay, &after, &[(stranger, Vec::new())]).await.unwrap();
16160        assert_eq!(n, 0, "no entitlement, no rotation");
16161    }
16162
16163    #[tokio::test]
16164    async fn ban_severance_refuses_without_manage_channels() {
16165        // Offer-side mirror of `channel_rotator_ok`: readers honor a channel
16166        // rotation only from MANAGE_CHANNELS holders, so a BAN-only moderator
16167        // publishing one would adopt an epoch every reader rejects — a fork.
16168        let (bed, owner, moderator) = TestBed::new();
16169        bed.swap_to(&owner);
16170        let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
16171        create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16172        let rid = "c1".repeat(32);
16173        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16174        publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
16175        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16176        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16177        let bundle_json = serde_json::to_string(&bundle).unwrap();
16178
16179        bed.swap_to(&moderator);
16180        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16181        let _ = follow_control(&bed.relay, &joined).await;
16182        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16183        let target = Keys::generate().public_key();
16184        let err = sever_banned_private_reads(&bed.relay, &joined, &[(target, Vec::new())]).await.unwrap_err();
16185        assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
16186    }
16187
16188    #[tokio::test]
16189    async fn set_banlist_refuses_an_author_the_fold_would_reject() {
16190        // The reader gates the banlist head on BAN and each added entry on strict
16191        // outrank. Publishing anyway would be silently void everywhere while the
16192        // author's own echo caches the phantom — fail-closed at the offer instead.
16193        let (bed, owner, member) = TestBed::new();
16194        bed.swap_to(&owner);
16195        let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
16196        let rid = "c2".repeat(32);
16197        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16198        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16199        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16200        let bundle_json = serde_json::to_string(&bundle).unwrap();
16201
16202        // A roleless member holds no BAN: refused before any publish.
16203        bed.swap_to(&member);
16204        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16205        let _ = follow_control(&bed.relay, &joined).await;
16206        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16207        let target = Keys::generate().public_key().to_hex();
16208        let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
16209        assert!(err.contains("BAN"), "refused for the missing bit: {err}");
16210        assert!(
16211            crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
16212            "no phantom echo cached"
16213        );
16214
16215        // Grant them BAN: adding a peer they don't outrank still refuses (the
16216        // fold drops per-target on outrank), while an unranked target passes.
16217        bed.swap_to(&owner);
16218        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
16219        let peer = Keys::generate().public_key();
16220        publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
16221        bed.swap_to(&member);
16222        let _ = follow_control(&bed.relay, &joined).await;
16223        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16224        let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
16225        assert!(err.contains("outrank"), "an equal is not actionable: {err}");
16226        set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
16227    }
16228
16229    #[tokio::test]
16230    async fn metadata_and_link_minting_refuse_unauthorized_authors() {
16231        // Both are reader-gated (MANAGE_METADATA; CREATE_INVITE on the registry) —
16232        // the offer must mirror or the SDK reports success on a void publish.
16233        let (bed, owner, member) = TestBed::new();
16234        bed.swap_to(&owner);
16235        let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
16236        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16237        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16238        let bundle_json = serde_json::to_string(&bundle).unwrap();
16239
16240        bed.swap_to(&member);
16241        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16242        let _ = follow_control(&bed.relay, &joined).await;
16243        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16244        let meta = control::CommunityMetadata {
16245            name: "Hijacked".into(),
16246            description: None,
16247            relays: vec![],
16248            icon: None,
16249            banner: None,
16250            custom: None,
16251            extra: Default::default(),
16252        };
16253        assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
16254        assert!(
16255            mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
16256            "link minting refused"
16257        );
16258    }
16259
16260    #[tokio::test]
16261    async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
16262        // JSKitty's race: the vend can land BEFORE the control fold that proves
16263        // the grant. It must park quietly (a lagging fold is not an anomaly) and
16264        // be adopted on the re-judge once the roster catches up.
16265        let (bed, owner, member) = TestBed::new();
16266        bed.swap_to(&owner);
16267        let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
16268        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16269        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16270        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16271        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16272        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
16273        let owner_hex = community.owner().unwrap().to_hex();
16274
16275        // Judge as the MEMBER — the owner is always entitled, so only a non-owner
16276        // can exercise the grant rule at all.
16277        bed.swap_to(&member);
16278        let me = member.keys.public_key().to_hex();
16279        // Their fold has the channel (control-follow records it keyless) but not
16280        // yet the grant that entitles them.
16281        let mut member_view = held.clone();
16282        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16283            c.key = None;
16284            c.epoch = Epoch(0);
16285        }
16286
16287        // Ungranted → PARK, never refuse: this is exactly the "not synced enough
16288        // to judge" case, and it must stay quiet and retryable.
16289        let empty = crate::community::roles::CommunityRoles::default();
16290        assert!(matches!(
16291            judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
16292            VendVerdict::Park(_)
16293        ));
16294
16295        // A channel our fold says is PUBLIC never heals — that's a spoof shape.
16296        let mut public_view = member_view.clone();
16297        if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16298            c.private = false;
16299        }
16300        assert!(matches!(
16301            judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
16302            VendVerdict::Refuse(_)
16303        ));
16304
16305        // An unknown channel parks (our fold may simply be behind), never refuses.
16306        assert!(matches!(
16307            judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
16308            VendVerdict::Park(_)
16309        ));
16310
16311        // Park the vend, then re-judge with a roster that still lacks our grant:
16312        // it must SURVIVE, not be discarded.
16313        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
16314        crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
16315        crate::db::community::save_community_v2(&member_view).unwrap();
16316        let session = crate::db::current_session();
16317        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16318        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
16319        assert_eq!(
16320            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16321            1,
16322            "and stays parked for the next fold"
16323        );
16324
16325        // The fold catches up: our grant lands, so the same vend now adopts.
16326        let access = crate::community::roles::Role {
16327            role_id: "44".repeat(32),
16328            name: "mods".into(),
16329            position: u32::MAX - 1,
16330            permissions: crate::community::roles::Permissions::empty(),
16331            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16332            color: 0,
16333        };
16334        let folded = crate::community::roles::CommunityRoles {
16335            grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
16336            roles: vec![access],
16337        };
16338        crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
16339        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16340        let adopted = absorb_parked_channel_keys(&reloaded, &session);
16341        assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
16342
16343        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16344        let ch = after.channel(&priv_id).unwrap();
16345        assert_eq!(ch.key, Some(real_key), "adopted the vended key");
16346        assert_eq!(ch.epoch, Epoch(1));
16347        assert!(
16348            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16349            "and the park is discharged"
16350        );
16351    }
16352
16353    #[tokio::test]
16354    async fn a_vend_from_an_unentitled_sender_is_never_seated_but_survives_for_the_real_one() {
16355        // Vend rule 4: the VENDOR must be entitled too. Every other test sends as
16356        // the owner, which short-circuits the check one line above it, so the rule
16357        // itself has never been exercised. Both halves matter — a stranger must not
16358        // seat a key, and their row must not consume the slot either, which is the
16359        // suppression the candidate table exists to prevent.
16360        let (bed, owner, member) = TestBed::new();
16361        bed.swap_to(&owner);
16362        let community = create_community(&bed.relay, "Vendors", bed.relays.clone(), None).await.unwrap();
16363        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16364        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16365        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16366        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16367        let owner_hex = community.owner().unwrap().to_hex();
16368
16369        bed.swap_to(&member);
16370        let mut member_view = held.clone();
16371        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16372            c.key = None;
16373            c.epoch = Epoch(0);
16374        }
16375        crate::db::community::save_community_v2(&member_view).unwrap();
16376
16377        // WE are entitled (rule 3 passes, so the judge reaches rule 4). The vendor
16378        // below is not, and is not the owner.
16379        let access = crate::community::roles::Role {
16380            role_id: "66".repeat(32),
16381            name: "mods".into(),
16382            position: u32::MAX - 1,
16383            permissions: crate::community::roles::Permissions::empty(),
16384            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16385            color: 0,
16386        };
16387        let roster = crate::community::roles::CommunityRoles {
16388            grants: vec![crate::community::roles::MemberGrant {
16389                member: member.keys.public_key().to_hex(),
16390                role_ids: vec![access.role_id.clone()],
16391            }],
16392            roles: vec![access],
16393        };
16394        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16395
16396        let stranger_hex = Keys::generate().public_key().to_hex();
16397        let junk = [0x11; 32];
16398        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &junk, &stranger_hex).unwrap();
16399
16400        let session = crate::db::current_session();
16401        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16402        assert!(
16403            absorb_parked_channel_keys(&reloaded, &session).is_empty(),
16404            "an unentitled vendor seats nothing"
16405        );
16406        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16407        assert_eq!(after.channel(&priv_id).unwrap().key, None, "the channel is still keyless");
16408        assert_eq!(
16409            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16410            1,
16411            "parked, not refused — the vendor's own entitlement may simply not have folded here yet"
16412        );
16413
16414        // The entitled vendor's key lands despite the stranger's row sitting there.
16415        let real = [0x5a; 32];
16416        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &real, &owner_hex).unwrap();
16417        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16418        assert_eq!(
16419            absorb_parked_channel_keys(&reloaded, &session).len(),
16420            1,
16421            "the genuine vend is seated — a stranger cannot occupy the slot"
16422        );
16423        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16424        assert_eq!(after.channel(&priv_id).unwrap().key, Some(real), "and it is the ENTITLED vendor's key");
16425        assert!(
16426            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16427            "both candidates discharge once one is seated"
16428        );
16429    }
16430
16431    #[tokio::test]
16432    async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
16433        // Live cross-client finding: a peer that mints born-private channels at
16434        // epoch 0 vends epoch 0, which collides with our keyless cursor (also 0).
16435        // The monotonic guard (`new > current`) would refuse the only key we are
16436        // ever offered, and refuse it SILENTLY. First delivery is not a rotation.
16437        let (bed, owner, member) = TestBed::new();
16438        bed.swap_to(&owner);
16439        let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
16440        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16441        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16442        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16443        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16444        let vended = [0x5a; 32];
16445        let owner_hex = community.owner().unwrap().to_hex();
16446
16447        bed.swap_to(&member);
16448        // The member's view: channel known, keyless, parked at the epoch-0 cursor.
16449        let mut member_view = held.clone();
16450        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16451            c.key = None;
16452            c.epoch = Epoch(0);
16453        }
16454        crate::db::community::save_community_v2(&member_view).unwrap();
16455
16456        // Entitle them, then park a vend AT EPOCH 0 (what the peer actually sends).
16457        let access = crate::community::roles::Role {
16458            role_id: "77".repeat(32),
16459            name: "mods".into(),
16460            position: u32::MAX - 1,
16461            permissions: crate::community::roles::Permissions::empty(),
16462            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16463            color: 0,
16464        };
16465        let roster = crate::community::roles::CommunityRoles {
16466            grants: vec![crate::community::roles::MemberGrant {
16467                member: member.keys.public_key().to_hex(),
16468                role_ids: vec![access.role_id.clone()],
16469            }],
16470            roles: vec![access],
16471        };
16472        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16473        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
16474
16475        let session = crate::db::current_session();
16476        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16477        let adopted = absorb_parked_channel_keys(&reloaded, &session);
16478        assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
16479
16480        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16481        let ch = after.channel(&priv_id).unwrap();
16482        assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
16483        assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
16484        assert!(
16485            !after.channel_read_coords(ch).is_empty(),
16486            "and the channel is readable — the whole point"
16487        );
16488        assert!(
16489            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16490            "the park is discharged"
16491        );
16492    }
16493
16494    #[tokio::test]
16495    async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
16496        // The channel head is MONOTONIC, so over-advancing it can never be walked
16497        // back: every genuine rotation afterwards lands at head+1, reads as stale,
16498        // and the channel dies for us with no heal path at all. An entitled
16499        // insider vending a garbage key costs isolation (accepted); one vending a
16500        // garbage EPOCH would cost the channel permanently, which is not.
16501        let (bed, owner, member) = TestBed::new();
16502        bed.swap_to(&owner);
16503        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
16504        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16505        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16506        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16507        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16508        let owner_hex = community.owner().unwrap().to_hex();
16509
16510        bed.swap_to(&member);
16511        let mut member_view = held.clone();
16512        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16513            c.key = None;
16514            c.epoch = Epoch(0);
16515        }
16516        crate::db::community::save_community_v2(&member_view).unwrap();
16517        let access = crate::community::roles::Role {
16518            role_id: "99".repeat(32),
16519            name: "mods".into(),
16520            position: u32::MAX - 1,
16521            permissions: crate::community::roles::Permissions::empty(),
16522            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16523            color: 0,
16524        };
16525        let roster = crate::community::roles::CommunityRoles {
16526            grants: vec![crate::community::roles::MemberGrant {
16527                member: member.keys.public_key().to_hex(),
16528                role_ids: vec![access.role_id.clone()],
16529            }],
16530            roles: vec![access],
16531        };
16532        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16533        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16534
16535        // Everything else about this vend is valid — only the epoch is absurd.
16536        assert!(matches!(
16537            judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
16538            VendVerdict::Refuse(_)
16539        ));
16540        // REFUSED, not parked: a row nothing can ever discharge is its own leak.
16541        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
16542        let session = crate::db::current_session();
16543        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
16544        assert!(
16545            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16546            "and the row is discharged rather than parked forever"
16547        );
16548        // The head is untouched, so the genuine vend still lands afterwards.
16549        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16550        assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
16551        assert!(matches!(
16552            judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
16553            VendVerdict::Accept
16554        ));
16555    }
16556
16557    #[tokio::test]
16558    async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
16559        // The fold is the authority but runs later, so publishing alone leaves the
16560        // edit reading back stale — it looks like the rename silently failed.
16561        let (_tmp, _guard, _owner) = init_test_db();
16562        let relay = MemoryRelay::new();
16563        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
16564        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16565        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16566        let key_before = held.channel(&priv_id).unwrap().key;
16567
16568        let mut meta = held.channel(&priv_id).unwrap().metadata();
16569        meta.name = "staff".into();
16570        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
16571
16572        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16573        let ch = after.channel(&priv_id).unwrap();
16574        assert_eq!(ch.name, "staff", "the rename is visible immediately");
16575        assert!(ch.private, "and privacy survives the edit");
16576        assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
16577    }
16578
16579    #[tokio::test]
16580    async fn a_channel_rename_carries_its_companion_access_role() {
16581        let (_tmp, _guard, _owner) = init_test_db();
16582        let relay = MemoryRelay::new();
16583        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
16584        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16585        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16586        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16587
16588        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16589        let before = roster.channel_roles(&chan_hex);
16590        assert_eq!(before.len(), 1, "one companion role, minted at create");
16591        assert_eq!(before[0].name, "mods", "named after the channel it gates");
16592        let role_id = before[0].role_id.clone();
16593
16594        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16595        let mut meta = held.channel(&priv_id).unwrap().metadata();
16596        meta.name = "staff".into();
16597        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
16598
16599        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16600        let after = roster.channel_roles(&chan_hex);
16601        assert_eq!(after.len(), 1, "renamed in place, never duplicated");
16602        assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
16603        assert_eq!(after[0].name, "staff", "the access role followed the channel");
16604        assert_eq!(
16605            after[0].permissions,
16606            crate::community::roles::Permissions::empty(),
16607            "and still confers read access, never authority"
16608        );
16609    }
16610
16611    #[tokio::test]
16612    async fn a_customised_access_role_name_survives_a_channel_rename() {
16613        // The label is cosmetic — entitlement rides the scope. Overwriting a name
16614        // someone chose deliberately is the surprising half of "keep them in step".
16615        let (_tmp, _guard, _owner) = init_test_db();
16616        let relay = MemoryRelay::new();
16617        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
16618        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16619        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16620        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16621
16622        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16623        let mut role = roster.channel_roles(&chan_hex)[0].clone();
16624        role.name = "Lab Insiders".into();
16625        set_role(&relay, &community, &role).await.unwrap();
16626        merge_local_roster(&cid_hex, Some(&role), None);
16627
16628        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16629        let mut meta = held.channel(&priv_id).unwrap().metadata();
16630        meta.name = "staff".into();
16631        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
16632
16633        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16634        assert_eq!(
16635            roster.channel_roles(&chan_hex)[0].name,
16636            "Lab Insiders",
16637            "a deliberate name is left alone"
16638        );
16639    }
16640
16641    #[tokio::test]
16642    async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
16643        // Parking is reachable by ANY npub that can gift-wrap us — the bundle
16644        // self-certifies and its inputs are public for a public community. With a
16645        // single slot per channel, a stranger could pre-park and the admin's real
16646        // vend would be a silent no-op, leaving the member keyless with no retry.
16647        // Candidates + judge-them-all is what closes that.
16648        let (bed, owner, member) = TestBed::new();
16649        bed.swap_to(&owner);
16650        let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
16651        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16652        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16653        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16654        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16655        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
16656        let owner_hex = community.owner().unwrap().to_hex();
16657
16658        bed.swap_to(&member);
16659        let mut member_view = held.clone();
16660        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16661            c.key = None;
16662            c.epoch = Epoch(0);
16663        }
16664        crate::db::community::save_community_v2(&member_view).unwrap();
16665        let access = crate::community::roles::Role {
16666            role_id: "aa".repeat(32),
16667            name: "mods".into(),
16668            position: u32::MAX - 1,
16669            permissions: crate::community::roles::Permissions::empty(),
16670            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16671            color: 0,
16672        };
16673        let roster = crate::community::roles::CommunityRoles {
16674            grants: vec![crate::community::roles::MemberGrant {
16675                member: member.keys.public_key().to_hex(),
16676                role_ids: vec![access.role_id.clone()],
16677            }],
16678            roles: vec![access],
16679        };
16680        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
16681
16682        // A stranger squats FIRST, at a higher epoch than the genuine vend.
16683        let stranger = Keys::generate().public_key().to_hex();
16684        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
16685        // The admin's real vend arrives after, at the true epoch.
16686        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
16687        assert_eq!(
16688            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16689            2,
16690            "the squatter never displaces the genuine vend — both are candidates"
16691        );
16692
16693        let session = crate::db::current_session();
16694        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16695        let adopted = absorb_parked_channel_keys(&reloaded, &session);
16696        assert_eq!(adopted.len(), 1, "exactly one adoption");
16697
16698        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16699        let ch = after.channel(&priv_id).unwrap();
16700        assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
16701        assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
16702        assert!(
16703            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
16704            "and every candidate for the channel is discharged"
16705        );
16706    }
16707
16708    #[tokio::test]
16709    async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
16710        // With no access role folded, the retained-set filter matches NOBODY, so
16711        // the rotation would cut off every legitimately entitled member while the
16712        // Grant it published revoked nothing. Reachable with no attacker: the
16713        // channel was made on another admin's client and its role hasn't folded.
16714        let (_tmp, _guard, _owner) = init_test_db();
16715        let relay = MemoryRelay::new();
16716        let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
16717        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16718        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16719        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16720        let before = held.channel(&priv_id).unwrap().epoch;
16721
16722        // Neither the cache nor the plane serves the access role — a withholding
16723        // relay, or a channel minted on another admin's client. (Wiping only the
16724        // cache is no longer enough: the revoke re-fetches authority first.)
16725        crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
16726        let mut blind = held.clone();
16727        blind.relays = vec!["wss://empty".into()];
16728        let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
16729            .await
16730            .unwrap_err();
16731        assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
16732
16733        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16734        assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
16735    }
16736
16737    // ── Live rekey-follow ────────────────────────────────────────────────────
16738
16739    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
16740    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
16741    /// a stranger for the authority test); `prev_key` is the root it claims to
16742    /// extend (mismatch → a fork).
16743    async fn publish_base_rotation(
16744        relay: &MemoryRelay,
16745        community: &CommunityV2,
16746        rotator: &Keys,
16747        recipients: &[PublicKey],
16748        new_root: &[u8; 32],
16749        prev_key: &[u8; 32],
16750    ) {
16751        let new_epoch = Epoch(community.root_epoch.0 + 1);
16752        let prev_epoch = community.root_epoch;
16753        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
16754        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
16755        let blobs: Vec<_> = recipients
16756            .iter()
16757            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
16758            .collect();
16759        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), false).unwrap();
16760        for e in &events {
16761            relay.publish(e, &community.relays).await.unwrap();
16762        }
16763    }
16764
16765    /// A base rotation that carries the containment marker (CORD-06 Severing).
16766    async fn publish_severing_base_rotation(
16767        relay: &MemoryRelay,
16768        community: &CommunityV2,
16769        rotator: &Keys,
16770        recipients: &[PublicKey],
16771        new_root: &[u8; 32],
16772        prev_key: &[u8; 32],
16773    ) {
16774        let new_epoch = Epoch(community.root_epoch.0 + 1);
16775        let prev_epoch = community.root_epoch;
16776        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
16777        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
16778        let blobs: Vec<_> = recipients
16779            .iter()
16780            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
16781            .collect();
16782        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), true).unwrap();
16783        for e in &events {
16784            relay.publish(e, &community.relays).await.unwrap();
16785        }
16786    }
16787
16788    /// Attach a Private channel (key + epoch) to a held community and persist it.
16789    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
16790        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
16791        crate::db::community::save_community_v2(community).unwrap();
16792    }
16793
16794    #[tokio::test]
16795    async fn follow_rekeys_is_a_noop_without_rotations() {
16796        let (_tmp, _guard, _owner) = init_test_db();
16797        let relay = MemoryRelay::new();
16798        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
16799        let session = crate::db::current_session();
16800        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16801        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
16802    }
16803
16804    #[tokio::test]
16805    async fn follow_rekeys_adopts_an_owner_base_rotation() {
16806        let (_tmp, _guard, owner) = init_test_db();
16807        let relay = MemoryRelay::new();
16808        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
16809        let new_root = [0xB1; 32];
16810        // Owner rotates the base to epoch 1, delivering the new root to me.
16811        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
16812
16813        let session = crate::db::current_session();
16814        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
16815        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
16816        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
16817        // The public channel now reads under the NEW root/epoch (its address moved).
16818        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
16819        let general = updated.channels[0].id;
16820        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
16821        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
16822    }
16823
16824    #[tokio::test]
16825    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
16826        let (_tmp, _guard, owner) = init_test_db();
16827        let relay = MemoryRelay::new();
16828        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
16829        let priv_id = ChannelId([0x33; 32]);
16830        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
16831
16832        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
16833        let new_key = [0x55; 32];
16834        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
16835        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
16836        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();
16837        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
16838        for e in &events {
16839            relay.publish(e, &community.relays).await.unwrap();
16840        }
16841
16842        let session = crate::db::current_session();
16843        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
16844        let ch = updated.channel(&priv_id).unwrap();
16845        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
16846        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
16847        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
16848    }
16849
16850    #[tokio::test]
16851    async fn a_whole_channel_fan_rotates_in_one_hop_without_crossing_keys() {
16852        // The fan is fetched concurrently and keyed by (channel, addressing root). A
16853        // mis-keyed prefetch cannot hand channel A's key to channel B — the blob's
16854        // scope is authenticated, so a wrong batch is refused rather than adopted — but
16855        // it CAN starve a channel of the chunks that are sitting on the plane for it,
16856        // which reads as "that channel never rotates" and strands its members.
16857        let (_tmp, _guard, owner) = init_test_db();
16858        let relay = MemoryRelay::new();
16859        let mut community = create_community(&relay, "FanRot", vec!["wss://r".into()], None).await.unwrap();
16860
16861        // Distinct held key AND distinct fresh key per channel — a swap between any
16862        // two is then visible as an equality failure rather than a silent pass.
16863        let fan: Vec<(ChannelId, [u8; 32], [u8; 32])> = (0u8..12)
16864            .map(|i| (ChannelId([0x30 + i; 32]), [0x40 + i; 32], [0x80 + i; 32]))
16865            .collect();
16866        for (id, held, _) in &fan {
16867            add_private_channel(&mut community, *id, *held, Epoch(0));
16868        }
16869
16870        for (id, held, fresh) in &fan {
16871            let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), held);
16872            let group = channel_rekey_group_key(&community.community_root, id, Epoch(1));
16873            let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(*id), Epoch(1), fresh).unwrap();
16874            let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(*id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
16875            for e in &events {
16876                relay.publish(e, &community.relays).await.unwrap();
16877            }
16878        }
16879
16880        let session = crate::db::current_session();
16881        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
16882        for (id, _, fresh) in &fan {
16883            let ch = updated.channel(id).expect("channel survived the fan");
16884            assert_eq!(ch.epoch, Epoch(1), "every channel in the fan advanced");
16885            assert_eq!(ch.key, Some(*fresh), "each channel adopted ITS OWN key, not a neighbour's");
16886        }
16887    }
16888
16889    #[tokio::test]
16890    async fn follow_rekeys_ignores_a_non_owner_rotation() {
16891        // A member holds the community_root, so they can derive the rekey group key
16892        // and mint a rotation — but they aren't the owner, so it's not adopted.
16893        let (_tmp, _guard, _owner) = init_test_db();
16894        let relay = MemoryRelay::new();
16895        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
16896        let rogue = Keys::generate();
16897        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
16898
16899        let session = crate::db::current_session();
16900        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16901        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
16902    }
16903
16904    #[tokio::test]
16905    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
16906        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
16907        // extension — never adopted (would splice me onto an unrelated chain).
16908        let (_tmp, _guard, owner) = init_test_db();
16909        let relay = MemoryRelay::new();
16910        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
16911        // prev_key ≠ the real community_root → the continuity check reads Fork.
16912        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
16913
16914        let session = crate::db::current_session();
16915        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16916        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
16917    }
16918
16919    #[tokio::test]
16920    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
16921        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
16922        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
16923        let (_tmp, _guard, owner) = init_test_db();
16924        let relay = MemoryRelay::new();
16925        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
16926        let new_epoch = Epoch(1);
16927        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
16928        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
16929        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
16930        let other = Keys::generate();
16931        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
16932        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None, false).unwrap();
16933        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
16934        relay.publish(&wrap, &community.relays).await.unwrap();
16935
16936        let session = crate::db::current_session();
16937        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16938        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
16939    }
16940
16941    #[tokio::test]
16942    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
16943        // Realistic two-actor removal: the owner Refounds the base and delivers the
16944        // new root to a THIRD party, not the member — a complete rotation with no
16945        // blob for the member is a removal.
16946        let (bed, owner, member) = TestBed::new();
16947        bed.swap_to(&owner);
16948        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
16949        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
16950
16951        bed.swap_to(&member);
16952        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
16953        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
16954
16955        // Owner rotates, delivering only to a stranger (the member is dropped).
16956        bed.swap_to(&owner);
16957        let stranger = Keys::generate();
16958        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
16959
16960        // The member's follow concludes removal (a complete rotation without their blob).
16961        bed.swap_to(&member);
16962        let session = crate::db::current_session();
16963        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
16964        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
16965        assert!(follow.updated.is_none(), "a removed member adopts nothing");
16966    }
16967
16968    #[tokio::test]
16969    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
16970        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
16971        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
16972        // the base crate landed and was walked before the channel crates) must
16973        // still find them — the lookup fans across the archived roots, not just
16974        // the current one.
16975        let (_tmp, _guard, owner) = init_test_db();
16976        let relay = MemoryRelay::new();
16977        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
16978        let root0 = community.community_root;
16979        let priv_id = ChannelId([0x33; 32]);
16980        let key1 = [0x44; 32];
16981        add_private_channel(&mut community, priv_id, key1, Epoch(1));
16982
16983        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
16984        // root (root0), delivering the fresh key to me.
16985        let key2 = [0x55; 32];
16986        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
16987        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
16988        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();
16989        for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None, false).unwrap() {
16990            relay.publish(&e, &community.relays).await.unwrap();
16991        }
16992
16993        // Simulate the base having ALREADY advanced (the stranding order): the head
16994        // moved to a fresh root while root0 sits in the epoch-key archive (where
16995        // genesis put it).
16996        community.community_root = [0xB7; 32];
16997        community.root_epoch = Epoch(1);
16998        crate::db::community::save_community_v2(&community).unwrap();
16999
17000        let session = crate::db::current_session();
17001        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
17002        let ch = updated.channel(&priv_id).unwrap();
17003        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
17004        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
17005    }
17006
17007    #[tokio::test]
17008    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
17009        // A keyless private channel (announced by vsk-2, key not yet held) has no
17010        // chain, so its epoch is a scan cursor: a complete rotation that excludes
17011        // us advances the cursor (never a removal — we were never in); a later
17012        // rotation that includes us is the entry point.
17013        let (_tmp, _guard, owner) = init_test_db();
17014        let relay = MemoryRelay::new();
17015        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
17016        let priv_id = ChannelId([0x66; 32]);
17017        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() });
17018        crate::db::community::save_community_v2(&community).unwrap();
17019        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17020        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
17021
17022        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
17023        let stranger = Keys::generate();
17024        let key1 = [0x71; 32];
17025        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17026        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
17027        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();
17028        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None, false).unwrap() {
17029            relay.publish(&e, &community.relays).await.unwrap();
17030        }
17031        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
17032        // recipient set is the CURRENT members).
17033        let key2 = [0x72; 32];
17034        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17035        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17036        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();
17037        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None, false).unwrap() {
17038            relay.publish(&e, &community.relays).await.unwrap();
17039        }
17040
17041        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
17042        // blob — adopt), because each real step re-loops.
17043        let session = crate::db::current_session();
17044        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
17045        let ch = updated.channel(&priv_id).unwrap();
17046        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
17047        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
17048    }
17049
17050    #[tokio::test]
17051    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
17052        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
17053        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
17054        // rotation keys members up; a mere keyholder's forgery never does.
17055        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17056        let (_tmp, _guard, _owner) = init_test_db();
17057        let relay = MemoryRelay::new();
17058        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
17059        let priv_id = ChannelId([0x88; 32]);
17060        let key1 = [0x91; 32];
17061        add_private_channel(&mut community, priv_id, key1, Epoch(1));
17062
17063        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
17064        let admin = Keys::generate();
17065        let role = Role::admin("aa".repeat(32));
17066        let roster = CommunityRoles {
17067            roles: vec![role.clone()],
17068            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
17069        };
17070        seed_roster_with_heads(&community, &roster, 1_000);
17071
17072        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
17073        let key2 = [0x92; 32];
17074        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17075        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17076        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17077        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17078        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref(), false).unwrap() {
17079            relay.publish(&e, &community.relays).await.unwrap();
17080        }
17081        let session = crate::db::current_session();
17082        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
17083        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
17084
17085        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
17086        let rogue = Keys::generate();
17087        let key3 = [0x93; 32];
17088        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
17089        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
17090        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
17091        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None, false).unwrap() {
17092            relay.publish(&e, &updated.relays).await.unwrap();
17093        }
17094        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
17095        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
17096    }
17097
17098    #[tokio::test]
17099    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
17100        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
17101        // target. An equal-rank bit-holder's complete rotation that skips my blob
17102        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
17103        // two-account bed: the follower must be a NON-owner admin.
17104        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17105        let (bed, owner, member) = TestBed::new();
17106        bed.swap_to(&owner);
17107        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
17108
17109        // The MEMBER's device: holds the community + the private channel, with a
17110        // persisted roster granting the member AND a peer the same Admin role.
17111        bed.swap_to(&member);
17112        let mut held = community.clone();
17113        let priv_id = ChannelId([0xAB; 32]);
17114        let key1 = [0xA1; 32];
17115        add_private_channel(&mut held, priv_id, key1, Epoch(1));
17116        let peer = Keys::generate();
17117        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17118        let role = Role::admin("bb".repeat(32));
17119        let roster = CommunityRoles {
17120            roles: vec![role.clone()],
17121            grants: vec![
17122                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17123                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17124            ],
17125        };
17126        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
17127
17128        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
17129        let key2 = [0xA2; 32];
17130        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17131        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
17132        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();
17133        for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref(), false).unwrap() {
17134            bed.relay.publish(&e, &held.relays).await.unwrap();
17135        }
17136        let session = crate::db::current_session();
17137        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17138        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
17139        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
17140        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
17141
17142        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
17143        let key3 = [0xA3; 32];
17144        let stranger = Keys::generate();
17145        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();
17146        for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None, false).unwrap() {
17147            bed.relay.publish(&e, &held.relays).await.unwrap();
17148        }
17149        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17150        let updated = follow.updated.expect("the owner's removal folds");
17151        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
17152    }
17153
17154    #[tokio::test]
17155    async fn converting_a_public_channel_to_private_is_refused() {
17156        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
17157        // the producer refuses the flag flip, so no reader is left unkeyable.
17158        let (_tmp, _guard, _owner) = init_test_db();
17159        let relay = MemoryRelay::new();
17160        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
17161        let general = community.channels[0].id;
17162        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
17163        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
17164        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
17165        // A rename of the same public channel still works.
17166        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
17167        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
17168    }
17169
17170    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
17171    /// `removed_at` — simulating a sibling device having left that community.
17172    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
17173        let doc = super::super::list::CommunityList {
17174            entries: vec![],
17175            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
17176            extra: Default::default(),
17177        };
17178        // A sibling device writes fragments, like any §8 client.
17179        for (i, frag) in super::super::list_frag::fragment(&doc).iter().enumerate() {
17180            let event = super::super::list_frag::build_fragment_event_keys(me, frag, i, now_ms() / 1000).unwrap();
17181            relay.publish(&event, relays).await.unwrap();
17182        }
17183    }
17184
17185    #[tokio::test]
17186    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
17187        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
17188        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
17189        // which would silently undo the leave everywhere.
17190        let (_tmp, _guard, me) = init_test_db();
17191        let relay = MemoryRelay::new();
17192        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
17193        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17194
17195        // A sibling leaves X: a remote tombstone strictly newer than X's add.
17196        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17197
17198        // Now join a different community Y → republish(just_joined = Y).
17199        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
17200        republish_community_list(&relay, Some(y.id())).await.unwrap();
17201
17202        // X must still read as LEFT in the published list; Y must be live.
17203        let list = fetch_fragments(&relay, &x.relays).await.unwrap().unwrap().list;
17204        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
17205        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
17206    }
17207
17208    #[tokio::test]
17209    async fn sync_tears_down_a_community_a_sibling_left() {
17210        // W1 (receive side): a community still held locally that the synced 13302
17211        // shows tombstoned-and-not-live is torn down, so a leave propagates.
17212        let (_tmp, _guard, me) = init_test_db();
17213        let relay = MemoryRelay::new();
17214        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
17215        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17216        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
17217
17218        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17219        sync_community_list(&relay, &x.relays).await.unwrap();
17220        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
17221    }
17222
17223    #[tokio::test]
17224    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
17225        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
17226        // the tombstone), so a later sync keeps it.
17227        let (_tmp, _guard, me) = init_test_db();
17228        let relay = MemoryRelay::new();
17229        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
17230        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17231        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
17232        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
17233        // Re-record the membership (a re-join) → live entry at now >> 1.
17234        republish_community_list(&relay, Some(x.id())).await.unwrap();
17235        sync_community_list(&relay, &x.relays).await.unwrap();
17236        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
17237    }
17238
17239    #[tokio::test]
17240    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
17241        // W2: a transient fetch failure during republish must not drive the
17242        // replaceable-event write (which would drop other entries / regress seeds).
17243        let (_tmp, _guard, _me) = init_test_db();
17244        let good = MemoryRelay::new();
17245        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
17246        assert!(fetch_fragments(&good, &community.relays).await.unwrap().is_some());
17247
17248        // A transport whose fetch always errors: republish must bail, publishing nothing.
17249        struct FetchErrors;
17250        #[async_trait::async_trait]
17251        impl Transport for FetchErrors {
17252            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
17253            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17254                panic!("republish must NOT publish when the remote fetch failed");
17255            }
17256            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17257                Ok(())
17258            }
17259            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
17260                Err("relay unreachable".to_string())
17261            }
17262        }
17263        // Returns Ok (best-effort) but must not have published (the panic guards it).
17264        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
17265    }
17266
17267    #[tokio::test]
17268    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
17269        // B1 regression: refound_community's recipient set = memberlist. A member
17270        // the owner GRANTED a role to but who never left a (surviving) Guestbook
17271        // Join — a lurking admin, or one whose Join aged out of the window — must
17272        // still be a rekey recipient, or the Refounding SEVERS them. The folded
17273        // roster's granted members are the consensus-complete backstop.
17274        let (_tmp, _guard, owner) = init_test_db();
17275        let relay = MemoryRelay::new();
17276        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
17277
17278        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
17279        let lurker = Keys::generate();
17280        let rid = "b1".repeat(32);
17281        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
17282        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
17283
17284        // memberlist includes the lurker purely via the roster backstop.
17285        let members = memberlist(&relay, &community).await.unwrap();
17286        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
17287
17288        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
17289        let banned_grantee = Keys::generate();
17290        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
17291        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
17292        let members = memberlist(&relay, &community).await.unwrap();
17293        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
17294        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
17295
17296        // And the Refounding actually delivers the new root to the lurker.
17297        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
17298        assert_eq!(refounded.root_epoch, Epoch(1));
17299        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
17300        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
17301        let rotations = rekey::collect_rotations(&chunks);
17302        let lurker_x = lurker.public_key().to_bytes();
17303        let delivered = rotations.iter().any(|r| {
17304            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
17305        });
17306        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
17307    }
17308
17309    #[tokio::test]
17310    async fn the_memberlist_pages_past_a_guestbook_flood() {
17311        // The roleless-member half of B1: >500 Guestbook events must not evict an
17312        // honest member's Join from the counted set (an insider can flood throwaway
17313        // Joins to force exactly this). The pager sees them all.
17314        let (_tmp, _guard, _owner) = init_test_db();
17315        let relay = MemoryRelay::new();
17316        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
17317        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
17318
17319        // An honest member's Join (oldest), then 600 throwaway Joins on top.
17320        let honest = Keys::generate();
17321        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
17322        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
17323        relay.publish(&w, &community.relays).await.unwrap();
17324        for i in 0..600u64 {
17325            let throwaway = Keys::generate();
17326            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
17327            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
17328            relay.publish(&w, &community.relays).await.unwrap();
17329        }
17330
17331        let members = memberlist(&relay, &community).await.unwrap();
17332        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
17333    }
17334
17335    #[tokio::test]
17336    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
17337        // An insider floods the next-epoch rekey address (community_root-derived,
17338        // so any member can seal there) with >200 junk 3303s to push the owner's
17339        // genuine rotation out of a single fetch window. The paginated fetch must
17340        // still recover it and adopt.
17341        let (_tmp, _guard, owner) = init_test_db();
17342        let relay = MemoryRelay::new();
17343        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
17344        let new_root = [0xD9; 32];
17345        let new_epoch = Epoch(1);
17346        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17347
17348        // The GENUINE owner rotation lands first (oldest).
17349        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
17350
17351        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
17352        // (newer), burying the genuine one past the 200 newest.
17353        let rogue = Keys::generate();
17354        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17355        for i in 0..260u64 {
17356            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
17357            let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None, false).unwrap();
17358            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
17359            relay.publish(&wrap, &community.relays).await.unwrap();
17360        }
17361
17362        let session = crate::db::current_session();
17363        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
17364        assert_eq!(updated.root_epoch, Epoch(1));
17365        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
17366    }
17367
17368    #[tokio::test]
17369    async fn a_swap_during_create_private_channel_lands_in_the_creating_account() {
17370        // create_private_channel publishes the key crate, then the channel
17371        // edition, then whole-row-saves. A swap in that window used to abandon
17372        // the whole thing — leaving the key crate published to relays but no
17373        // local channel to open it, which is the worse of the two outcomes.
17374        //
17375        // The operation is pinned to the account that started it, so it now
17376        // finishes into that account's storage, and the account swapped in gets
17377        // nothing.
17378        let (bed, owner, _member) = TestBed::new();
17379        bed.swap_to(&owner);
17380        let creator = crate::db::current_session();
17381        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
17382        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
17383
17384        // The key-crate publish inside create swaps the account mid-flight.
17385        let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
17386        create_private_channel(&swap_relay, &community, "ghost").await.unwrap();
17387
17388        let mine = crate::db::with_session(creator, async {
17389            crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
17390        })
17391        .await;
17392        assert_eq!(mine.channels.len(), before + 1, "the channel is minted for the account that asked");
17393        assert!(mine.channels.iter().any(|c| c.name == "ghost"), "and it is the one requested");
17394    }
17395
17396    #[tokio::test]
17397    async fn an_uncited_admin_rotation_is_not_adopted() {
17398        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
17399        // authority action, so a just-demoted admin's rotation is never honored by
17400        // a lagging client." An uncited rotation is skipped entirely — neither
17401        // adopted nor allowed to conclude a removal.
17402        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17403        let (_tmp, _guard, _owner) = init_test_db();
17404        let relay = MemoryRelay::new();
17405        let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
17406        let priv_id = ChannelId([0x8A; 32]);
17407        let key1 = [0x93; 32];
17408        add_private_channel(&mut community, priv_id, key1, Epoch(1));
17409
17410        let admin = Keys::generate();
17411        let role = Role::admin("cf".repeat(32));
17412        let roster = CommunityRoles {
17413            roles: vec![role.clone()],
17414            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
17415        };
17416        seed_roster_with_heads(&community, &roster, 1_000);
17417
17418        let key2 = [0x94; 32];
17419        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17420        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17421        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17422        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17423        // Authorized admin, correct continuity, my blob present — but NO citation.
17424        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None, false).unwrap() {
17425            relay.publish(&e, &community.relays).await.unwrap();
17426        }
17427
17428        let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
17429        assert!(out.updated.is_none(), "an uncited rotation is not adopted");
17430
17431        // The SAME rotation, cited, is adopted — proving the refusal was the
17432        // citation and not the rank or the continuity.
17433        let cited = my_authority_citation(&community, &admin.public_key());
17434        assert!(cited.is_some(), "the seeded head yields a citation");
17435        let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17436        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref(), false).unwrap() {
17437            relay.publish(&e, &community.relays).await.unwrap();
17438        }
17439        let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
17440        assert!(out.updated.is_some(), "the cited rotation IS adopted");
17441    }
17442
17443    #[tokio::test]
17444    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
17445        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
17446        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
17447        // Every follower must converge on the SAME key (the lexicographically
17448        // lowest), so the community never permanently forks. (Retaining the losing
17449        // fork's key for its race-window messages needs a multi-key-per-epoch
17450        // archive — a deferred refinement shared with v1; convergence, the
17451        // security-critical property, is what this pins.)
17452        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17453        let (_tmp, _guard, _owner) = init_test_db();
17454        let relay = MemoryRelay::new();
17455        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
17456        let priv_id = ChannelId([0xC0; 32]);
17457        let key1 = [0xC1; 32];
17458        add_private_channel(&mut community, priv_id, key1, Epoch(1));
17459
17460        // Two admins (a, b) both hold the Admin role; I hold the channel key.
17461        let (a, b) = (Keys::generate(), Keys::generate());
17462        let role = Role::admin("ce".repeat(32));
17463        let roster = CommunityRoles {
17464            roles: vec![role.clone()],
17465            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
17466        };
17467        seed_roster_with_heads(&community, &roster, 1_000);
17468
17469        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
17470        // same prevcommit — a genuine same-epoch fork.
17471        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17472        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17473        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17474        let key_a = [0x0A; 32];
17475        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
17476        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
17477            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
17478            for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref(), false).unwrap() {
17479                relay.publish(&e, &community.relays).await.unwrap();
17480            }
17481        }
17482
17483        let session = crate::db::current_session();
17484        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
17485        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
17486        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
17487
17488        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
17489        let mut peer = community.clone();
17490        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17491            c.key = Some(key1);
17492            c.epoch = Epoch(1);
17493        }
17494        // Re-run the same fold from the peer's identical starting point → same winner.
17495        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
17496        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
17497    }
17498
17499    #[tokio::test]
17500    async fn create_private_channel_refuses_a_member_without_manage_channels() {
17501        // The local mirror of the reader's gate: an unauthorized member is refused
17502        // BEFORE any publish (no floor pollution, no orphan key crate).
17503        let (bed, owner, member) = TestBed::new();
17504        bed.swap_to(&owner);
17505        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
17506        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
17507
17508        bed.swap_to(&member);
17509        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
17510        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
17511        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
17512        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
17513        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
17514        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
17515    }
17516
17517    // ── Audit regressions ────────────────────────────────────────────────────
17518
17519    #[tokio::test]
17520    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
17521        // The eclipse: community_id commits only to (owner, salt) — both semi-public
17522        // — so a forged invite pairs the REAL triple with an attacker root, and every
17523        // plane derives from it. The join-time owner-genesis check must refuse.
17524        let (bed, owner, member) = TestBed::new();
17525        bed.swap_to(&owner);
17526        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
17527
17528        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
17529        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
17530        forged.community_root = fake.clone();
17531        for ch in &mut forged.channels {
17532            ch.key = fake.clone();
17533        }
17534        let attacker = Keys::generate();
17535        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
17536        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
17537
17538        bed.swap_to(&member);
17539        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
17540        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
17541        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
17542        assert!(
17543            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
17544            "a rejected join persists nothing"
17545        );
17546    }
17547
17548    #[tokio::test]
17549    async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
17550        // CORD-06 compaction re-wraps CURRENT heads with their original signatures,
17551        // so a rotated plane whose metadata an admin last edited carries no
17552        // owner-signed vsk-0. The join anchor there is the community-bound metadata
17553        // head plus any owner-signed edition under the same root.
17554        let (bed, owner, member) = TestBed::new();
17555        bed.swap_to(&owner);
17556        let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
17557        let general = community.channels[0].id;
17558
17559        let rotated = rotate_view(&community, [0x5A; 32], 1);
17560        let admin = Keys::generate();
17561        publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
17562        publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
17563
17564        bed.swap_to(&member);
17565        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
17566        let joined = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17567        assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
17568    }
17569
17570    #[tokio::test]
17571    async fn only_an_actual_join_publishes_a_guestbook_join() {
17572        // A Guestbook Join is a member's own word that they JOINED. A re-accept of
17573        // a held community and a cross-device key sync (announce_join=false) must
17574        // both stay silent — each re-publish renders as "<user> has joined" spam.
17575        let (bed, owner, member) = TestBed::new();
17576        bed.swap_to(&owner);
17577        let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
17578
17579        let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
17580        async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
17581            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
17582            relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
17583        }
17584        let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; // the owner's creation Join
17585
17586        bed.swap_to(&member);
17587        let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
17588        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17589        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
17590
17591        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17592        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
17593
17594        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17595        crate::db::community::delete_community(&cid_hex).unwrap();
17596        // A key sync is not a membership event, and it is not a membership
17597        // RECORD either: the entry was just read from the very list a republish
17598        // would rewrite. A device adopting N entries used to rebuild and
17599        // publish the whole document N times over, concurrently, on boot.
17600        // 13302 is REPLACEABLE, so a republish swaps the stored event rather
17601        // than adding one — the id is the tell, and it always changes on a
17602        // real republish because the list is NIP-44 sealed with a random nonce.
17603        async fn list_id(relay: &MemoryRelay, me: &PublicKey, relays: &[String]) -> Option<String> {
17604            let q = Query { kinds: vec![crate::community::v2::kind::COMMUNITY_LIST_FRAG], authors: vec![me.to_hex()], ..Default::default() };
17605            let mut evs = relay.fetch(&q, relays).await.unwrap_or_default();
17606            evs.sort_by_key(|e| e.created_at.as_secs());
17607            evs.last().map(|e| e.id.to_hex())
17608        }
17609        let my_pk = me_pk().unwrap();
17610        let list_before = list_id(&bed.relay, &my_pk, &bed.relays).await;
17611        accept_bundle(&bed.relay, &bundle, None, false).await.unwrap();
17612        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
17613        assert_eq!(
17614            list_id(&bed.relay, &my_pk, &bed.relays).await,
17615            list_before,
17616            "adopting an entry FROM the list must not republish the list"
17617        );
17618
17619        // A genuine join still records, so the storm fix can't have muted the
17620        // thing the republish exists for.
17621        crate::db::community::delete_community(&cid_hex).unwrap();
17622        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
17623        assert_ne!(
17624            list_id(&bed.relay, &my_pk, &bed.relays).await,
17625            list_before,
17626            "a real join still records the membership across devices"
17627        );
17628    }
17629
17630    #[tokio::test]
17631    async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
17632        // The fallback's second half is load-bearing: a community-bound metadata
17633        // head alone is self-signable by anyone who knows the (public) community_id.
17634        let (bed, owner, member) = TestBed::new();
17635        bed.swap_to(&owner);
17636        let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
17637
17638        let rotated = rotate_view(&community, [0x5B; 32], 1);
17639        let attacker = Keys::generate();
17640        publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
17641
17642        bed.swap_to(&member);
17643        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
17644        let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
17645        assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
17646    }
17647
17648    #[tokio::test]
17649    async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
17650        // The fallback applies to rotated planes only: at epoch 0 the spec guarantees
17651        // an owner-signed genesis, so owner material without it stays insufficient.
17652        let (bed, owner, member) = TestBed::new();
17653        bed.swap_to(&owner);
17654        let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
17655        let general = community.channels[0].id;
17656
17657        let mut fake = community.clone();
17658        fake.community_root = [0x5C; 32]; // epoch stays 0
17659        let admin = Keys::generate();
17660        publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
17661        publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
17662
17663        bed.swap_to(&member);
17664        let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
17665        let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
17666        assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
17667    }
17668
17669    #[tokio::test]
17670    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
17671        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
17672        // joiner addresses it at a plane only the attacker reads. The owner's genuine
17673        // public:false edition must override it on follow.
17674        let (_tmp, _guard, _owner) = init_test_db();
17675        let relay = MemoryRelay::new();
17676        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
17677        let general = community.channels[0].id;
17678        let mut poisoned = community.clone();
17679        poisoned.channels[0].private = true;
17680        poisoned.channels[0].key = Some([0x66; 32]);
17681        crate::db::community::save_community_v2(&poisoned).unwrap();
17682
17683        let healed = follow_control(&relay, &poisoned).await.unwrap().updated.expect("healed");
17684        let ch = healed.channel(&general).unwrap();
17685        assert!(!ch.private, "the owner's public declaration overrides the bundle");
17686        assert_eq!(ch.key, None, "a healed public channel derives from the root");
17687    }
17688
17689    #[tokio::test]
17690    async fn a_deleted_channel_does_not_resurrect_on_reload() {
17691        // save_community_v2 must prune orphan channel rows, or a control-follow delete
17692        // reappears (with a stale key) on the next reload.
17693        let (_tmp, _guard, owner) = init_test_db();
17694        let relay = MemoryRelay::new();
17695        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
17696        let extra = ChannelId([0x77; 32]);
17697        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
17698        let with_extra = follow_control(&relay, &community).await.unwrap().updated.unwrap();
17699        assert!(with_extra.channel(&extra).is_some());
17700        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
17701        let after = follow_control(&relay, &with_extra).await.unwrap().updated.unwrap();
17702        assert!(after.channel(&extra).is_none());
17703
17704        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17705        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
17706        assert_eq!(reloaded.channels.len(), 1);
17707    }
17708
17709    #[tokio::test]
17710    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
17711        // channel_id is the sole DB primary key, so a bundle/replay reusing another
17712        // community's channel_id must NOT overwrite that row. It's skipped (not an
17713        // error — erroring would wedge all of this community's control persistence).
17714        let (_tmp, _guard, _owner) = init_test_db();
17715        let relay = MemoryRelay::new();
17716        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
17717        let a_channel = a.channels[0].id;
17718        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
17719        let b_channel = b.channels[0].id;
17720        // B's set includes a phantom whose id collides with A's channel.
17721        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() });
17722
17723        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
17724        // A's channel row is untouched.
17725        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
17726        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
17727        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
17728        // B keeps its own channel but never acquired a row for the foreign id.
17729        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
17730        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
17731        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
17732    }
17733
17734    /// A single relay that CAPS every query below the page size (modelling a real
17735    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
17736    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
17737    /// truncates the whole set), which is why a MemoryRelay flood test gives false
17738    /// confidence about the production `LiveTransport` behaviour.
17739    struct CappedRelay {
17740        events: Vec<Event>,
17741        cap: usize,
17742    }
17743    #[async_trait::async_trait]
17744    impl Transport for CappedRelay {
17745        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
17746        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17747            Ok(())
17748        }
17749        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17750            Ok(())
17751        }
17752        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
17753            let mut m: Vec<Event> = self
17754                .events
17755                .iter()
17756                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
17757                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
17758                .cloned()
17759                .collect();
17760            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
17761            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
17762            Ok(m)
17763        }
17764    }
17765
17766    #[tokio::test]
17767    async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
17768        // CORD-06 §3: a Refounder that cannot fold every Control Event must abort.
17769        // `until` is inclusive, so a page-wide block of same-second wraps is a wall
17770        // no cursor steps past — everything older (the genesis editions, a Banlist)
17771        // is unreachable. Compacting THAT view carries only what was read into the
17772        // new epoch, dropping the rest for every member, permanently. Any member can
17773        // build the wall: the plane key comes from the community root they hold.
17774        let (_tmp, _guard, _owner) = init_test_db();
17775        let memory = MemoryRelay::new();
17776        let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
17777        // Post-split the plane is staff-write-only, so the wall is built with the
17778        // write group (any control_root holder can still flood; the pager
17779        // property under test is unchanged).
17780        let control = control::ControlPlane::of(&community).write_group().unwrap();
17781
17782        let rogue = Keys::generate();
17783        let mut events: Vec<Event> = Vec::new();
17784        for i in 0..FOLLOW_PAGE {
17785            let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
17786            let rumor = control::build_edition_rumor(
17787                rogue.public_key(),
17788                vsk::CHANNEL_METADATA,
17789                &[0xAB; 32],
17790                1,
17791                None,
17792                &content,
17793                9_000,
17794                None,
17795            );
17796            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
17797            events.push(w);
17798        }
17799        let relay = CappedRelay { events, cap: FOLLOW_PAGE };
17800
17801        let err = refound_community(&relay, &community, &[])
17802            .await
17803            .expect_err("a plane that can't be read whole must never be compacted");
17804        assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
17805    }
17806
17807    #[tokio::test]
17808    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
17809        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
17810        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
17811        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
17812        // timestamps so `until = now` includes everything and the walk is deterministic.
17813        let (_tmp, _guard, owner) = init_test_db();
17814        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
17815        let g = control::genesis(&owner, meta, 1_000).unwrap();
17816        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
17817
17818        let control = control::ControlPlane::of(&community).write_group().unwrap();
17819        let rogue = Keys::generate();
17820        let mut events: Vec<Event> = g.wraps.to_vec();
17821        for i in 0..250u64 {
17822            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
17823            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
17824            events.push(wrap);
17825        }
17826        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
17827        let relay = CappedRelay { events, cap: 100 };
17828        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
17829        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
17830    }
17831
17832    #[tokio::test]
17833    async fn accept_parked_invite_joins_from_the_stored_bundle() {
17834        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
17835        // from the stored bundle (re-verifying the owner root over the network).
17836        let (bed, owner, member) = TestBed::new();
17837        bed.swap_to(&owner);
17838        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
17839        let general = community.channels[0].id;
17840        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
17841        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
17842        let bundle_json = serde_json::to_string(&bundle).unwrap();
17843        let inviter_hex = owner.keys.public_key().to_hex();
17844
17845        bed.swap_to(&member);
17846        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
17847        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
17848        assert!(joined.identity.verify());
17849        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
17850        // The join seeded the verified fold as the member's initial floor, so their
17851        // first follow can't roll below the state the join just showed.
17852        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
17853        assert!(
17854            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
17855            "the joiner's control floor is seeded from the join-time fold"
17856        );
17857
17858        // The Guestbook memberlist now folds both participants.
17859        bed.swap_to(&owner);
17860        let members = memberlist(&bed.relay, &community).await.unwrap();
17861        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
17862    }
17863
17864    #[tokio::test]
17865    async fn accept_parked_invite_rejects_a_forged_root() {
17866        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
17867        // accept — the shared accept path re-verifies the owner root, so a parked invite
17868        // gets the same eclipse protection as a live one.
17869        let (_tmp, _guard, _owner) = init_test_db();
17870        let relay = MemoryRelay::new();
17871        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
17872        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
17873        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
17874        forged.community_root = fake.clone();
17875        for ch in &mut forged.channels {
17876            ch.key = fake.clone();
17877        }
17878        let bundle_json = serde_json::to_string(&forged).unwrap();
17879
17880        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
17881        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
17882    }
17883
17884    #[test]
17885    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
17886        // The protocol discriminator the facade list/accept relies on: a v2 bundle
17887        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
17888        // one does not, so a parked invite routes to the right accept path.
17889        let owner = Keys::generate();
17890        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
17891        let hex = crate::simd::hex::bytes_to_hex_32;
17892        let v2 = invite::CommunityInvite {
17893            community_id: hex(&identity.community_id.0),
17894            owner: hex(&identity.owner_xonly),
17895            owner_salt: hex(&identity.owner_salt),
17896            community_root: hex(&[0x11; 32]),
17897            root_epoch: 0,
17898            control_pk: None,
17899            channels: vec![],
17900            relays: vec!["wss://r".into()],
17901            name: "V2".into(),
17902            icon: None,
17903            expires_at: None,
17904            creator_npub: None,
17905            label: None,
17906            extra: Default::default(),
17907        };
17908        let v2_json = serde_json::to_string(&v2).unwrap();
17909        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
17910        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
17911        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
17912    }
17913
17914    #[tokio::test]
17915    async fn verify_rejects_a_cross_community_owner_edition_replay() {
17916        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
17917        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
17918        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
17919        // a different eid — is not a valid proof of T's root. This is why "any owner
17920        // edition" is unsound and the eid==community_id genesis pin is required.
17921        let (_tmp, _guard, owner) = init_test_db();
17922
17923        // Community X (real), owned by `owner`.
17924        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
17925        let x_control = control::split_write_group(&gx.control_root, &gx.community_root, &gx.identity.community_id, Epoch(0));
17926        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
17927
17928        // Forged community T: the real owner triple but an ATTACKER-chosen root.
17929        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
17930        let fake_root = [0xEE; 32];
17931        let t = CommunityV2 {
17932            identity: t_identity,
17933            community_root: fake_root,
17934            root_epoch: Epoch(0),
17935            control_pk: None,
17936            control_root: None,
17937            name: "T".into(),
17938            description: None,
17939            icon: None,
17940            banner: None,
17941            meta_custom: None,
17942            meta_extra: Default::default(),
17943            relays: vec!["wss://r".into()],
17944            channels: vec![],
17945            dissolved: false,
17946            created_at_ms: 0,
17947        };
17948        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
17949        // controls the fake root, so they can derive its control group key).
17950        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
17951        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
17952        let relay = MemoryRelay::new();
17953        relay.publish(&replayed, &t.relays).await.unwrap();
17954
17955        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
17956        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
17957    }
17958
17959    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
17960    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
17961    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
17962    /// it never touches real accounts. Run explicitly:
17963    /// ```sh
17964    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
17965    /// ```
17966    #[tokio::test]
17967    #[ignore = "hits a real relay over the network"]
17968    async fn live_smoke_create_send_fetch_on_a_real_relay() {
17969        use crate::community::transport::LiveTransport;
17970        use nostr_sdk::prelude::ToBech32;
17971
17972        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
17973        let relays = vec![relay.clone()];
17974
17975        // Isolated account + data dir (a fresh throwaway key — never a real account).
17976        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
17977        crate::db::close_database();
17978        crate::db::clear_id_caches();
17979        let tmp = tempfile::tempdir().unwrap();
17980        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
17981        // into elsewhere; otherwise a fresh throwaway.
17982        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
17983            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
17984            Err(_) => Keys::generate(),
17985        };
17986        let npub = keys.public_key().to_bech32().unwrap();
17987        // Off by default (never leak secrets from a committed test); set
17988        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
17989        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
17990            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
17991        }
17992        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
17993        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
17994        crate::db::set_current_account(npub.clone()).unwrap();
17995        crate::db::init_database(&npub).unwrap();
17996        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
17997        crate::state::set_my_public_key(keys.public_key());
17998        println!("[smoke] throwaway identity {npub}");
17999
18000        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
18001        let client = crate::nostr_client_builder().build();
18002        client.add_managed_relay(relay.as_str()).await.ok();
18003        client.connect().await;
18004        crate::state::set_nostr_client(client);
18005        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
18006
18007        // Create → send → fetch-back → verify.
18008        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
18009        let general = community.channels[0].id;
18010        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
18011
18012        let text = "hello from a Vector Concord v2 live smoke test";
18013        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
18014        println!("[smoke] sent message {sent_id}");
18015
18016        // Give the relay a moment to store + be ready to serve it.
18017        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
18018
18019        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
18020        let texts: Vec<String> = page
18021            .iter()
18022            .filter_map(|f| match &f.event {
18023                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
18024                _ => None,
18025            })
18026            .collect();
18027        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
18028        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
18029
18030        // Mint a shareable v2 link (the thing a bot hands out).
18031        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
18032        println!("[smoke] invite link: {}", link.url);
18033        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
18034    }
18035
18036    #[tokio::test]
18037    async fn chat_ops_react_edit_delete_round_trip() {
18038        let (bed, owner, _member) = TestBed::new();
18039        bed.swap_to(&owner);
18040        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
18041        let general = community.channels[0].id;
18042        let me_hex = owner.keys.public_key().to_hex();
18043
18044        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
18045        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
18046            .await
18047            .unwrap();
18048        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
18049        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
18050
18051        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18052        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
18053        let mut saw = (false, false, false);
18054        for f in &page {
18055            match &f.event {
18056                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
18057                    assert_eq!(emoji, ":fire:");
18058                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
18059                    saw.0 = true;
18060                }
18061                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
18062                    assert_eq!(new_content, "edited");
18063                    saw.1 = true;
18064                }
18065                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
18066                _ => {}
18067            }
18068        }
18069        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
18070    }
18071
18072    #[tokio::test]
18073    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
18074        let (bed, owner, _member) = TestBed::new();
18075        bed.swap_to(&owner);
18076        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
18077        let general = community.channels[0].id;
18078        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18079
18080        // A live subscriber sees the 21059 wrap and it opens as Typing…
18081        let mut sub = bed.relay.subscribe(Query {
18082            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
18083            authors: vec![group.pk_hex()],
18084            ..Default::default()
18085        });
18086        send_typing(&bed.relay, &community, &general).await.unwrap();
18087        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
18088        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
18089            Ok(ChatEvent::Typing { opened }) => opened,
18090            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
18091        };
18092
18093        // …while nothing durable is stored (relays never keep the ephemeral tier),
18094        // so channel history stays free of typing noise…
18095        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18096        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
18097
18098        // …and no scrub key is retained (there is no durable wrap to ever delete).
18099        assert!(
18100            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
18101            "ephemeral sends must not retain scrub keys"
18102        );
18103    }
18104
18105    #[tokio::test]
18106    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
18107        let (bed, owner, _member) = TestBed::new();
18108        bed.swap_to(&owner);
18109        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
18110        let general = community.channels[0].id;
18111        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18112
18113        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
18114
18115        // Retained: the row maps the rumor id to the exact published wrap, holds the
18116        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
18117        let (keys, outer_hex, relays) =
18118            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
18119        assert_eq!(relays, community.relays);
18120        let wrap_query = Query {
18121            kinds: vec![stream::KIND_WRAP],
18122            authors: vec![group.pk_hex()],
18123            ..Default::default()
18124        };
18125        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18126        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
18127        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
18128
18129        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
18130        let me_hex = owner.keys.public_key().to_hex();
18131        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
18132            .await
18133            .unwrap();
18134        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
18135
18136        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
18137        // scrubs the wrap off the relay via the retained key, then consumes the row.
18138        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18139        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
18140        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18141        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
18142    }
18143
18144    #[tokio::test]
18145    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
18146        let (bed, owner, _member) = TestBed::new();
18147        bed.swap_to(&owner);
18148        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
18149        let general = community.channels[0].id;
18150        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18151
18152        // Simulate a pre-retention / other-device send: our message on the relay,
18153        // but no local mapping row.
18154        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
18155        crate::db::community::delete_message_key(&id).unwrap();
18156        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
18157
18158        // A stranger member's message rides the same channel.
18159        let mkeys = Keys::generate();
18160        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
18161        let foreign_id = rumor.id.unwrap().to_hex();
18162        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
18163        bed.relay.publish(&fw, &community.relays).await.unwrap();
18164
18165        // One history open re-derives the mapping for the OWN message…
18166        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18167        let (keys, _outer, relays) =
18168            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
18169        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
18170        assert_eq!(relays, community.relays);
18171
18172        // …and never manufactures one for a foreign author.
18173        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
18174
18175        // The healed row is a working full delete: the shared path scrubs the wrap.
18176        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18177        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18178        assert!(
18179            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
18180            "healed message scrubbed from the relay"
18181        );
18182    }
18183
18184    #[tokio::test]
18185    async fn send_chat_message_threads_the_reply_and_extra_tags() {
18186        let (bed, owner, _member) = TestBed::new();
18187        bed.swap_to(&owner);
18188        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
18189        let general = community.channels[0].id;
18190        let me_hex = owner.keys.public_key().to_hex();
18191
18192        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
18193        let imeta = nostr_sdk::prelude::Tag::custom(
18194            "imeta",
18195            ["url https://e/blob".to_string(), "m image/png".to_string()],
18196        );
18197        let child_id = send_chat_message(
18198            &bed.relay, &community, &general, "child",
18199            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
18200        )
18201        .await
18202        .unwrap();
18203
18204        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18205        let child = page
18206            .iter()
18207            .find_map(|f| match &f.event {
18208                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
18209                _ => None,
18210            })
18211            .expect("the reply message round-trips");
18212        let reply = child.1.as_ref().expect("the reply reference is carried");
18213        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
18214        assert_eq!(reply.author, Some(owner.keys.public_key()));
18215        assert!(
18216            child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
18217            "the imeta attachment tag rides the rumor verbatim"
18218        );
18219    }
18220
18221    #[tokio::test]
18222    async fn a_kick_needs_kick_authority_and_removes_the_target() {
18223        let (bed, owner, member) = TestBed::new();
18224        bed.swap_to(&owner);
18225        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
18226
18227        // The target announces a Join (as an accepted invite would).
18228        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
18229        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
18230        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
18231        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
18232        let before = memberlist(&bed.relay, &community).await.unwrap();
18233        assert!(before.contains(&member.keys.public_key()), "the join lands first");
18234
18235        // An unprivileged member's kick of the owner is refused locally…
18236        bed.swap_to(&member);
18237        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
18238        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
18239
18240        // …and the owner (supreme, no grant needed) kicks the member out.
18241        bed.swap_to(&owner);
18242        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18243        let after = memberlist(&bed.relay, &community).await.unwrap();
18244        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
18245        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
18246    }
18247
18248    #[tokio::test]
18249    async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
18250        // The self-eviction race: on a REJOIN the guestbook store starts empty while the
18251        // control fold has already re-derived the member's old ban mark, so the MEMBERLIST
18252        // legitimately excludes them for that window. A stale Kick landing there used to
18253        // read as an authorized eviction and the client nuked its own community.
18254        let (bed, owner, member) = TestBed::new();
18255        bed.swap_to(&owner);
18256        let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
18257        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18258        let (o, m) = (owner.keys.public_key(), member.keys.public_key());
18259        let join = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18260            rumor_id: [id; 32],
18261            entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
18262        };
18263        let kick = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18264            rumor_id: [id; 32],
18265            entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
18266        };
18267
18268        // An authorized kick after their join stands.
18269        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
18270        assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
18271
18272        // A rejoin supersedes it — latest entry wins (CORD-02 §5).
18273        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
18274        assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
18275
18276        // The catch-up window itself: nothing folded yet decides nothing.
18277        crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
18278        assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
18279
18280        // And the memberlist is NOT a substitute: with the store empty it excludes them,
18281        // which is exactly the false positive this verdict replaced.
18282        assert!(
18283            !stored_memberlist(&community).unwrap().contains(&m),
18284            "the memberlist excludes an un-caught-up member — why it can't gate a kick"
18285        );
18286    }
18287
18288    /// Seed a roster the way production does: `follow_control` writes the roster
18289    /// AND the folded edition heads in one pass, so a citation against a grant is
18290    /// resolvable. Seeding the roster alone yields a client that can never satisfy
18291    /// any `vac` — a shape no v2 production path produces.
18292    fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
18293        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18294        crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
18295        for g in &roster.grants {
18296            let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
18297            let eid = super::super::derive::grant_locator(community.id(), &m);
18298            let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
18299            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
18300        }
18301    }
18302
18303    /// Publish an edition CITING a specific grant version (CORD-04 §5's `vac`).
18304    async fn publish_grant_citing(
18305        relay: &MemoryRelay,
18306        community: &CommunityV2,
18307        signer: &Keys,
18308        member: &PublicKey,
18309        role_ids: Vec<String>,
18310        version: u64,
18311        citation: Option<&crate::community::edition::AuthorityCitation>,
18312    ) {
18313        let group = control::ControlPlane::of(&community).write_group().unwrap();
18314        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
18315        let prev = head_hash_on_relay(relay, community, &eid).await;
18316        let grant = MemberGrant { member: member.to_hex(), role_ids };
18317        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
18318        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
18319        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
18320        relay.publish(&wrap, &community.relays).await.unwrap();
18321    }
18322
18323    #[tokio::test]
18324    async fn an_uncited_edition_cannot_self_promote_or_outlive_a_demotion() {
18325        // The two ways an ADD could escalate if the citation were the thing holding
18326        // the line. It is not — rank is — and rank is resolved against the CURRENT
18327        // roster, which is precisely what the spec says the verifier must do.
18328        let (bed, owner, admin) = TestBed::new();
18329        bed.swap_to(&owner);
18330        let community = create_community(&bed.relay, "NoEscalate", bed.relays.clone(), None).await.unwrap();
18331        let admin_pk = admin.keys.public_key();
18332
18333        // An admin at position 3, and a role ABOVE them at position 1.
18334        let mid = "d1".repeat(32);
18335        let mut mid_role = admin_role(&mid, Permissions::admin().0);
18336        mid_role.position = 3;
18337        publish_role(&bed.relay, &community, &owner.keys, &mid_role, 1).await;
18338        let high = "d2".repeat(32);
18339        let mut high_role = admin_role(&high, Permissions::admin().0);
18340        high_role.position = 1;
18341        publish_role(&bed.relay, &community, &owner.keys, &high_role, 1).await;
18342        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![mid.clone()], 1).await;
18343
18344        // 1. SELF-PROMOTION, uncited: the admin grants THEMSELVES the higher role.
18345        publish_grant_citing(&bed.relay, &community, &admin.keys, &admin_pk, vec![high.clone(), mid.clone()], 2, None).await;
18346        // Assert the PROPERTY, not the shape: the role must not be held, whether the
18347        // forged edition was refused outright or folded back to the owner's version.
18348        let view = fetch_authority(&bed.relay, &community).await;
18349        assert!(
18350            !view.roles.grants.iter().any(|g| g.member == admin_pk.to_hex() && g.role_ids.contains(&high)),
18351            "an uncited edition must not let an author grant themselves a role they do not \
18352             outrank — you cannot act at or above your own rank, citation or not",
18353        );
18354
18355        // 2. OUTLIVING A DEMOTION, uncited: the owner demotes the admin to nothing,
18356        //    and the admin's uncited grant to a bystander must stop being honored the
18357        //    moment this client folds the demotion.
18358        let bystander = Keys::generate().public_key();
18359        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![mid.clone()], 1, None).await;
18360        let before = fetch_authority(&bed.relay, &community).await;
18361        assert!(
18362            !before.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18363            "an admin at position 3 cannot grant position 3 — equal cannot act on equal",
18364        );
18365
18366        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![], 3).await;
18367        let after = fetch_authority(&bed.relay, &community).await;
18368        assert!(
18369            !after.roles.is_admin(&admin_pk.to_hex()),
18370            "the owner's demotion folds",
18371        );
18372        assert!(
18373            !after.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18374            "and nothing the demoted author wrote is honored afterwards — authority is \
18375             resolved against the CURRENT roster, which is what the citation was only ever \
18376             a scheduling hint for",
18377        );
18378    }
18379
18380    #[tokio::test]
18381    async fn an_uncited_edition_folds_on_rank_alone_and_never_escalates() {
18382        // CORD-04 §5 calls the `vac` a SYNC FLOOR, not the verdict: a verifier waits
18383        // until it has synced the cited Grant, then resolves rank against its CURRENT
18384        // roster. Refusing an edition that cites NOTHING promoted that sync aid into an
18385        // authority requirement — and pre-citation clients emitted no `vac` at all, so
18386        // whole live rosters folded to nothing while a lenient implementation read the
18387        // same plane fine.
18388        //
18389        // What actually prevents escalation is rank, and rank is unchanged. This pins
18390        // both halves: a genuine in-rank edition folds without a citation, and an
18391        // out-of-rank one does not, cited or otherwise.
18392        let (bed, owner, admin) = TestBed::new();
18393        bed.swap_to(&owner);
18394        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
18395        let admin_pk = admin.keys.public_key();
18396        let rid = "c3".repeat(32);
18397        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
18398        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
18399
18400        // A role BENEATH the admin (they sit at position 1).
18401        let low_rid = "c4".repeat(32);
18402        let mut low = admin_role(&low_rid, Permissions::admin().0);
18403        low.position = 5;
18404        publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
18405
18406        // 1. GENUINE and in-rank, citing nothing → folds.
18407        let bystander = Keys::generate().public_key();
18408        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
18409        let view = fetch_authority(&bed.relay, &community).await;
18410        assert!(
18411            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18412            "an uncited edition from an author the roster DOES authorize, acting beneath \
18413             their own rank, is genuine and must fold",
18414        );
18415        assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant still folds");
18416
18417        // 2. ESCALATION, uncited: a stranger the roster authorizes for nothing.
18418        let stranger = Keys::generate();
18419        let victim = Keys::generate().public_key();
18420        publish_grant_citing(&bed.relay, &community, &stranger, &victim, vec![low_rid.clone()], 1, None).await;
18421        let view = fetch_authority(&bed.relay, &community).await;
18422        assert!(
18423            !view.roles.grants.iter().any(|g| g.member == victim.to_hex()),
18424            "an uncited edition from an UNAUTHORIZED author must never fold — dropping the \
18425             citation requirement must not become a way in",
18426        );
18427
18428        // 3. ESCALATION, uncited, by a real admin acting AT their own rank: equal
18429        //    cannot act on equal, citation or not.
18430        let peer = Keys::generate().public_key();
18431        publish_grant_citing(&bed.relay, &community, &admin.keys, &peer, vec![rid.clone()], 1, None).await;
18432        let view = fetch_authority(&bed.relay, &community).await;
18433        assert!(
18434            !view.roles.grants.iter().any(|g| g.member == peer.to_hex()),
18435            "an authorized author still cannot grant AT their own rank — rank is what \
18436             stops escalation, and it is untouched",
18437        );
18438
18439        // 4. ESCALATION, uncited REVOCATION — the case that makes the citation
18440        //    load-bearing. A strip names no roles, so the rank gate has no position to
18441        //    check and passes vacuously; the citation is the only thing standing there.
18442        //    An uncited edition may ADD authority, never remove it.
18443        publish_grant_citing(&bed.relay, &community, &stranger, &bystander, vec![], 9, None).await;
18444        let view = fetch_authority(&bed.relay, &community).await;
18445        assert!(
18446            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
18447            "an uncited empty grant must NOT strip a member — a revocation's rank check is \
18448             vacuous, so tolerating it uncited hands any stranger a demotion primitive",
18449        );
18450    }
18451
18452    #[tokio::test]
18453    async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
18454        // The invite is deliberately ungated, so a fresh Join can arrive seconds
18455        // BEFORE the unban edition. It must reach the store (banned = a fold
18456        // verdict, not a storage verdict) so the unban resurrects the member —
18457        // dropped at ingest, they stayed invisible forever.
18458        let (bed, owner, member) = TestBed::new();
18459        bed.swap_to(&owner);
18460        let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
18461        let member_pk = member.keys.public_key();
18462        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18463
18464        // Locally banned (edition folded at t=1000s), with the outliving mark.
18465        crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
18466        crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
18467
18468        // Their Join lands 60s after the ban mark, while the banlist still says banned.
18469        let join = guestbook::GuestbookEvent { epoch: None, observed_at: None,
18470            rumor_id: [9u8; 32],
18471            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
18472        };
18473        assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
18474        assert!(
18475            !stored_memberlist(&community).unwrap().contains(&member_pk),
18476            "while banned, the fold keeps them out"
18477        );
18478
18479        // The unban folds: same store, no refetch needed — the Join resurrects them.
18480        crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
18481        assert!(
18482            stored_memberlist(&community).unwrap().contains(&member_pk),
18483            "after the unban the raced Join makes them a member again"
18484        );
18485    }
18486
18487    #[tokio::test]
18488    async fn a_stale_root_admin_write_is_refused_not_misdirected() {
18489        // The ban→unban race: a Ban's refound buries the old root over several
18490        // publishes while a concurrently-issued command still holds the
18491        // pre-commit struct. That unban used to land on the buried control
18492        // plane — "succeeding" while no reader would ever fold it — and a
18493        // concurrently-minted invite stranded its joiner on the dead epoch.
18494        let (bed, owner, member) = TestBed::new();
18495        bed.swap_to(&owner);
18496        let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
18497        let member_pk = member.keys.public_key();
18498
18499        set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
18500        let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
18501
18502        // The stale-struct unban is REFUSED (retryable), never misdirected.
18503        let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
18504        assert!(err.contains("re-founded"), "unban: {err}");
18505        // A stale invite must not mint dead-epoch key material.
18506        let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
18507        assert!(err.contains("re-founded"), "invite: {err}");
18508        // Neither is a kick allowed to ride the buried guestbook.
18509        let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
18510        assert!(err.contains("re-founded"), "kick: {err}");
18511
18512        // The retry path: a fresh load lands the unban on the LIVING plane.
18513        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18514        set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
18515        let view = fetch_authority(&bed.relay, &fresh).await;
18516        assert!(view.banned.is_empty(), "the retried unban actually unbans");
18517    }
18518
18519    /// [`MemoryRelay`] with a cooperative yield before every call, so two units
18520    /// driven by one `join!` genuinely interleave at each network boundary — on
18521    /// the single-threaded test runtime the bare relay resolves without yielding,
18522    /// the first unit runs to completion, and the race never happens.
18523    struct YieldyRelay<'a>(&'a MemoryRelay);
18524    #[async_trait::async_trait]
18525    impl Transport for YieldyRelay<'_> {
18526        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
18527            tokio::task::yield_now().await;
18528            self.0.publish(event, relays).await
18529        }
18530        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
18531            tokio::task::yield_now().await;
18532            self.0.fetch(query, relays).await
18533        }
18534        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
18535            tokio::task::yield_now().await;
18536            self.0.fetch_plane(plane, query, relays).await
18537        }
18538        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
18539            tokio::task::yield_now().await;
18540            self.0.publish_durable(event, relays).await
18541        }
18542    }
18543
18544    #[tokio::test]
18545    async fn concurrent_bans_compose_instead_of_erasing_each_other() {
18546        // The SDK spawns a handler task per message, so a spam wave runs N ban
18547        // units concurrently. Unserialized, both read the pre-sibling banlist,
18548        // the second head erases the first target at every reader, and the
18549        // refound compacts that erasure into the new epoch permanently.
18550        let (bed, owner, _member) = TestBed::new();
18551        bed.swap_to(&owner);
18552        let community = create_community(&bed.relay, "Wave", bed.relays.clone(), None).await.unwrap();
18553        let a = Keys::generate().public_key();
18554        let b = Keys::generate().public_key();
18555
18556        let yieldy = YieldyRelay(&bed.relay);
18557        let (wave_a, wave_b) = ([a], [b]);
18558        let (ra, rb) = tokio::join!(
18559            set_members_banned(&yieldy, community.id(), &wave_a, true),
18560            set_members_banned(&yieldy, community.id(), &wave_b, true),
18561        );
18562        ra.unwrap();
18563        rb.unwrap();
18564
18565        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18566        assert_eq!(fresh.root_epoch, Epoch(2), "each single ban still performed its own read cut");
18567        let view = fetch_authority(&bed.relay, &fresh).await;
18568        assert!(
18569            view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
18570            "both racing bans landed: {:?}",
18571            view.banned
18572        );
18573    }
18574
18575    #[tokio::test]
18576    async fn bans_stacked_behind_a_rotation_coalesce_into_one_read_cut() {
18577        // Human-speed serial bans: the leader's read cut holds the lock for a
18578        // whole rotation, so bans arriving meanwhile deposit intents. The next
18579        // lock holder drains ALL of them — one edition, one rotation, however
18580        // many stacked. Worst case for a burst of N is 2 rotations, never N.
18581        let (bed, owner, _member) = TestBed::new();
18582        bed.swap_to(&owner);
18583        let community = create_community(&bed.relay, "Stack", bed.relays.clone(), None).await.unwrap();
18584        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18585        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
18586        let (a, b, c, d) = (
18587            Keys::generate().public_key(),
18588            Keys::generate().public_key(),
18589            Keys::generate().public_key(),
18590            Keys::generate().public_key(),
18591        );
18592
18593        let yieldy = YieldyRelay(&bed.relay);
18594        let (wa, wb, wc, wd) = ([a], [b], [c], [d]);
18595        // The first future drains itself and rotates; the other three stack
18596        // behind that rotation and coalesce under the next leader.
18597        let (ra, rb, rc, rd) = tokio::join!(
18598            set_members_banned(&yieldy, community.id(), &wa, true),
18599            set_members_banned(&yieldy, community.id(), &wb, true),
18600            set_members_banned(&yieldy, community.id(), &wc, true),
18601            set_members_banned(&yieldy, community.id(), &wd, true),
18602        );
18603        ra.unwrap();
18604        rb.unwrap();
18605        rc.unwrap();
18606        rd.unwrap();
18607
18608        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18609        assert_eq!(fresh.root_epoch, Epoch(2), "leader rotation + ONE coalesced rotation, never four");
18610        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
18611        assert_eq!(version, 2, "two editions total: the leader's and the coalesced drain's");
18612        let view = fetch_authority(&bed.relay, &fresh).await;
18613        for pk in [a, b, c, d] {
18614            assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
18615        }
18616    }
18617
18618    #[tokio::test]
18619    async fn a_stacked_ban_and_unban_coalesce_in_arrival_order() {
18620        // The drain applies intents in arrival order over the evolving list, so
18621        // ban(X) … unban(X) stacked behind one rotation nets X out entirely and
18622        // only the net newly-banned member costs a read cut.
18623        let (bed, owner, _member) = TestBed::new();
18624        bed.swap_to(&owner);
18625        let community = create_community(&bed.relay, "Net", bed.relays.clone(), None).await.unwrap();
18626        let x = Keys::generate().public_key();
18627        let y = Keys::generate().public_key();
18628
18629        let yieldy = YieldyRelay(&bed.relay);
18630        let (wx, wy) = ([x], [y]);
18631        let (rx1, ry, rx2) = tokio::join!(
18632            set_members_banned(&yieldy, community.id(), &wx, true),  // leader: rotates on X
18633            set_members_banned(&yieldy, community.id(), &wy, true),  // stacked
18634            set_members_banned(&yieldy, community.id(), &wx, false), // stacked: nets X back out
18635        );
18636        rx1.unwrap();
18637        ry.unwrap();
18638        rx2.unwrap();
18639
18640        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18641        assert_eq!(fresh.root_epoch, Epoch(2), "X's rotation + one coalesced rotation for Y");
18642        let view = fetch_authority(&bed.relay, &fresh).await;
18643        assert!(!view.banned.contains(&x.to_hex()), "the stacked unban netted X out");
18644        assert!(view.banned.contains(&y.to_hex()), "Y's stacked ban landed");
18645    }
18646
18647    #[tokio::test]
18648    async fn a_second_member_folds_every_stacked_ban() {
18649        // Derek's observer divergence, inverted: the whole point of composing the
18650        // batch correctly is that an INDEPENDENT reader folding the same plane
18651        // agrees. A fresh joiner (separate account DB, post-rotation bundle) must
18652        // see every stacked ban — not just the leader's own echo.
18653        let (bed, owner, member) = TestBed::new();
18654        bed.swap_to(&owner);
18655        let community = create_community(&bed.relay, "Agree", bed.relays.clone(), None).await.unwrap();
18656        let a = Keys::generate().public_key();
18657        let b = Keys::generate().public_key();
18658
18659        let yieldy = YieldyRelay(&bed.relay);
18660        let (wa, wb) = ([a], [b]);
18661        let (ra, rb) = tokio::join!(
18662            set_members_banned(&yieldy, community.id(), &wa, true),
18663            set_members_banned(&yieldy, community.id(), &wb, true),
18664        );
18665        ra.unwrap();
18666        rb.unwrap();
18667
18668        // Post-rotation bundle, exactly what a live link would serve now.
18669        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18670        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
18671        let bundle_json = serde_json::to_string(&bundle).unwrap();
18672
18673        bed.swap_to(&member);
18674        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
18675        let _ = follow_control(&bed.relay, &joined).await;
18676        let view = fetch_authority(&bed.relay, &joined).await;
18677        assert!(
18678            view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
18679            "an independent reader folds BOTH stacked bans: {:?}",
18680            view.banned
18681        );
18682    }
18683
18684    #[tokio::test]
18685    async fn a_net_zero_batch_publishes_no_edition() {
18686        // ban(X) + unban(X) stacked in one drain nets to the original list — the
18687        // batch must not spend an edition (or a rotation) publishing a no-op.
18688        let (bed, owner, _member) = TestBed::new();
18689        bed.swap_to(&owner);
18690        let community = create_community(&bed.relay, "Noop", bed.relays.clone(), None).await.unwrap();
18691        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18692        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
18693        let x = Keys::generate().public_key();
18694
18695        // The leader's slot is taken by a harmless real ban so X's ban+unban land
18696        // in ONE coalesced drain together.
18697        let decoy = Keys::generate().public_key();
18698        let yieldy = YieldyRelay(&bed.relay);
18699        let (wd, wx) = ([decoy], [x]);
18700        let (rd, r1, r2) = tokio::join!(
18701            set_members_banned(&yieldy, community.id(), &wd, true),
18702            set_members_banned(&yieldy, community.id(), &wx, true),
18703            set_members_banned(&yieldy, community.id(), &wx, false),
18704        );
18705        rd.unwrap();
18706        r1.unwrap();
18707        r2.unwrap();
18708
18709        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18710        assert_eq!(fresh.root_epoch, Epoch(1), "only the decoy's rotation — the net-zero drain rotated nothing");
18711        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
18712        assert_eq!(version, 1, "only the decoy's edition — the net-zero drain published nothing");
18713        let view = fetch_authority(&bed.relay, &fresh).await;
18714        assert!(!view.banned.contains(&x.to_hex()));
18715        assert!(view.banned.contains(&decoy.to_hex()));
18716    }
18717
18718    /// Fetches fine, refuses every publish — the dead-network shape.
18719    struct BrokenPublish<'a>(&'a MemoryRelay);
18720    #[async_trait::async_trait]
18721    impl Transport for BrokenPublish<'_> {
18722        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18723            tokio::task::yield_now().await;
18724            Err("relay refused".to_string())
18725        }
18726        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
18727            tokio::task::yield_now().await;
18728            self.0.fetch(query, relays).await
18729        }
18730        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
18731            tokio::task::yield_now().await;
18732            self.0.fetch_plane(plane, query, relays).await
18733        }
18734        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18735            tokio::task::yield_now().await;
18736            Err("relay refused".to_string())
18737        }
18738    }
18739
18740    #[tokio::test]
18741    async fn a_failed_publish_answers_every_stacked_caller_and_never_hangs() {
18742        // The batch's shared fate: when the edition can't land, every stacked
18743        // intent must resolve with the error — a waiter left parked on a dead
18744        // oneshot would hang a moderation bot forever.
18745        let (bed, owner, _member) = TestBed::new();
18746        bed.swap_to(&owner);
18747        let community = create_community(&bed.relay, "Dead", bed.relays.clone(), None).await.unwrap();
18748        let a = Keys::generate().public_key();
18749        let b = Keys::generate().public_key();
18750
18751        let broken = BrokenPublish(&bed.relay);
18752        let (wa, wb) = ([a], [b]);
18753        let (ra, rb) = tokio::time::timeout(
18754            std::time::Duration::from_secs(30),
18755            async {
18756                tokio::join!(
18757                    set_members_banned(&broken, community.id(), &wa, true),
18758                    set_members_banned(&broken, community.id(), &wb, true),
18759                )
18760            },
18761        )
18762        .await
18763        .expect("stacked callers must resolve, never hang");
18764        assert!(ra.is_err(), "the leader surfaced the publish failure");
18765        assert!(rb.is_err(), "the stacked caller surfaced it too");
18766
18767        // Nothing landed: a later fold shows no bans.
18768        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18769        let view = fetch_authority(&bed.relay, &fresh).await;
18770        assert!(view.banned.is_empty(), "no half-published state: {:?}", view.banned);
18771        assert_eq!(fresh.root_epoch, Epoch(0), "no rotation on a failed edition");
18772    }
18773
18774    #[tokio::test]
18775    async fn a_poison_intent_fails_alone_not_its_batchmates() {
18776        // Per-intent verdicts: an add that busts the 500 ceiling answers ITS
18777        // caller with the refusal while its batch-mates publish normally.
18778        let (bed, owner, _member) = TestBed::new();
18779        bed.swap_to(&owner);
18780        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
18781        // Pre-fill to 498 so the leader lands 499, the first stacked ban lands
18782        // exactly 500, and the second stacked ban would be 501.
18783        let filler: Vec<String> = (0..498).map(|i| format!("{i:064x}")).collect();
18784        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18785        set_banlist(&bed.relay, &held, &filler).await.unwrap();
18786        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18787        crate::db::community::set_community_banlist(&cid_hex, &filler, 1).unwrap();
18788
18789        let (a, x, y) = (Keys::generate().public_key(), Keys::generate().public_key(), Keys::generate().public_key());
18790        let yieldy = YieldyRelay(&bed.relay);
18791        let (wa, wx, wy) = ([a], [x], [y]);
18792        let (ra, rx, ry) = tokio::join!(
18793            set_members_banned(&yieldy, community.id(), &wa, true),
18794            set_members_banned(&yieldy, community.id(), &wx, true),
18795            set_members_banned(&yieldy, community.id(), &wy, true),
18796        );
18797        ra.unwrap();
18798        rx.unwrap();
18799        let err = ry.unwrap_err();
18800        assert!(err.contains("ceiling"), "the over-cap intent failed alone: {err}");
18801
18802        let list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
18803        assert_eq!(list.len(), 500, "the batch published up to the ceiling");
18804        assert!(list.contains(&x.to_hex()) && !list.contains(&y.to_hex()));
18805    }
18806
18807    #[tokio::test]
18808    async fn ban_many_is_one_edition_one_refound_for_the_whole_wave() {
18809        let (bed, owner, _member) = TestBed::new();
18810        bed.swap_to(&owner);
18811        let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
18812        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18813        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
18814        let wave: Vec<PublicKey> = (0..3).map(|_| Keys::generate().public_key()).collect();
18815
18816        set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap();
18817
18818        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18819        assert_eq!(fresh.root_epoch, Epoch(1), "one refound for the whole wave, not one per target");
18820        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
18821        assert_eq!(version, 1, "one banlist edition carried the whole wave");
18822        let view = fetch_authority(&bed.relay, &fresh).await;
18823        for pk in &wave {
18824            assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
18825        }
18826
18827        // The reductive mirror: unban two of three in one edition, no read cut.
18828        set_members_banned(&bed.relay, community.id(), &wave[..2], false).await.unwrap();
18829        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18830        assert_eq!(fresh.root_epoch, Epoch(1), "an unban rotates nothing");
18831        let view = fetch_authority(&bed.relay, &fresh).await;
18832        assert_eq!(view.banned.len(), 1, "reductive: exactly the un-unbanned target remains");
18833        assert!(view.banned.contains(&wave[2].to_hex()));
18834    }
18835
18836    #[tokio::test]
18837    async fn a_batch_past_the_banlist_cap_refuses_before_publishing() {
18838        let (bed, owner, _member) = TestBed::new();
18839        bed.swap_to(&owner);
18840        let community = create_community(&bed.relay, "Cap", bed.relays.clone(), None).await.unwrap();
18841        let wave: Vec<PublicKey> = (0..=crate::community::v2::roles::MAX_BANLIST).map(|_| Keys::generate().public_key()).collect();
18842
18843        let err = set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap_err();
18844        assert!(err.contains("ceiling"), "refused at the cap: {err}");
18845        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18846        assert_eq!(fresh.root_epoch, Epoch(0), "nothing rotated");
18847        let view = fetch_authority(&bed.relay, &fresh).await;
18848        assert!(view.banned.is_empty(), "nothing published");
18849    }
18850
18851    #[tokio::test]
18852    async fn an_uncited_kick_from_an_admin_is_not_honored() {
18853        // CORD-04 §5: a non-owner authority action must name the Grant it acts
18854        // under, and the reader refuses until it holds that Grant. Emitting the
18855        // `vac` without checking it buys nothing — a demoted admin's kick would
18856        // still land on any client that hadn't synced the demotion.
18857        let (bed, owner, member) = TestBed::new();
18858        bed.swap_to(&owner);
18859        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
18860        let admin = Keys::generate();
18861        let member_pk = member.keys.public_key();
18862        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
18863
18864        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18865        let view = fetch_authority(&bed.relay, &community).await;
18866        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
18867
18868        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
18869        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
18870        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
18871        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
18872
18873        let joined = guestbook::GuestbookEvent { epoch: None, observed_at: None,
18874            rumor_id: [1u8; 32],
18875            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
18876        };
18877        let kick = |citation, id: u8, at| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18878            rumor_id: [id; 32],
18879            entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
18880        };
18881        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
18882        let empty_bans = std::collections::BTreeSet::new();
18883        let empty_marks = std::collections::BTreeMap::new();
18884        let fold = |evs: &[guestbook::GuestbookEvent]| {
18885            fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
18886        };
18887
18888        assert!(
18889            fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
18890            "an uncited kick from an admin is not honored"
18891        );
18892        assert!(
18893            !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
18894            "the same kick WITH its synced citation removes them"
18895        );
18896    }
18897
18898    #[tokio::test]
18899    async fn kicking_an_admin_strips_their_roles_first() {
18900        // CORD-04 §6 composition: Role Removal THEN the directive. Kicking without the
18901        // strip leaves the target out of the memberlist but still holding every
18902        // management bit, so every client keeps honoring their control editions.
18903        let (bed, owner, member) = TestBed::new();
18904        bed.swap_to(&owner);
18905        let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
18906        let member_pk = member.keys.public_key();
18907        let member_hex = member_pk.to_hex();
18908        let owner_hex = owner.keys.public_key().to_hex();
18909
18910        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
18911        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
18912
18913        kick_member(&bed.relay, &community, &member_pk).await.unwrap();
18914
18915        let view = fetch_authority(&bed.relay, &community).await;
18916        assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
18917        assert!(
18918            !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
18919            "a kicked admin holds no bit"
18920        );
18921        assert!(
18922            !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
18923            "and the directive still removed them"
18924        );
18925    }
18926
18927    #[tokio::test]
18928    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
18929        let (bed, owner, member) = TestBed::new();
18930        bed.swap_to(&owner);
18931        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
18932        let member_pk = member.keys.public_key();
18933        let member_hex = member_pk.to_hex();
18934        let owner_hex = owner.keys.public_key().to_hex();
18935
18936        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
18937        let view = fetch_authority(&bed.relay, &community).await;
18938        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
18939        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
18940
18941        // A second grant (any device) converges on the SAME role entity — and a
18942        // repeat is a no-op, not a version bump.
18943        let second = Keys::generate().public_key();
18944        grant_admin(&bed.relay, &community, &second).await.unwrap();
18945        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
18946        let view = fetch_authority(&bed.relay, &community).await;
18947        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
18948        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
18949        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
18950        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
18951
18952        // Revoke strips ONLY the admin role and de-authorizes.
18953        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
18954        let view = fetch_authority(&bed.relay, &community).await;
18955        assert!(!view.roles.is_admin(&member_hex), "revoked");
18956        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
18957        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
18958    }
18959
18960    #[tokio::test]
18961    async fn follow_control_persists_the_roster_for_sync_local_reads() {
18962        let (bed, owner, member) = TestBed::new();
18963        bed.swap_to(&owner);
18964        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
18965        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18966        let member_hex = member.keys.public_key().to_hex();
18967        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18968
18969        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
18970        follow_control(&bed.relay, &community).await.unwrap().updated;
18971        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
18972        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
18973
18974        // A withholding relay serves nothing — an empty fold raises no gap flag, and
18975        // the stored roster must be RETAINED, never wiped.
18976        let withholding = MemoryRelay::new();
18977        let _ = follow_control(&withholding, &community).await;
18978        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
18979        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
18980
18981        // A real revocation (a NEWER grant edition) does replace it.
18982        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18983        follow_control(&bed.relay, &community).await.unwrap().updated;
18984        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
18985        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
18986    }
18987
18988    #[tokio::test]
18989    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
18990        let (bed, owner, member) = TestBed::new();
18991        bed.swap_to(&owner);
18992        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
18993
18994        bed.swap_to(&member);
18995        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
18996        assert!(err.contains("owner"), "refused before any publish: {err}");
18997
18998        // The deterministic admin-role entity stays unsquatted — the owner's later
18999        // legitimate mint is version 1 and folds cleanly.
19000        bed.swap_to(&owner);
19001        let view = fetch_authority(&bed.relay, &community).await;
19002        assert!(view.roles.roles.is_empty(), "no role edition landed");
19003        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19004        let view = fetch_authority(&bed.relay, &community).await;
19005        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
19006    }
19007
19008    #[tokio::test]
19009    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
19010        let (bed, owner, member) = TestBed::new();
19011        bed.swap_to(&owner);
19012        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
19013        let member_pk = member.keys.public_key();
19014
19015        // The member already holds a Mod role, granted through the real send path
19016        // (so this device's floors track both entities).
19017        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
19018        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
19019        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
19020
19021        // A relay that withholds the control plane must refuse the merge — a blind
19022        // push would erase the Mod role at a higher version.
19023        let withholding = MemoryRelay::new();
19024        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
19025        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
19026
19027        // Against the full relay the merge preserves the Mod role.
19028        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19029        let view = fetch_authority(&bed.relay, &community).await;
19030        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
19031        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
19032        assert!(grant.role_ids.contains(&mod_rid));
19033    }
19034
19035    #[tokio::test]
19036    async fn fetch_authority_reflects_a_granted_admin() {
19037        let (bed, owner, member) = TestBed::new();
19038        bed.swap_to(&owner);
19039        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
19040        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
19041        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
19042        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
19043
19044        let view = fetch_authority(&bed.relay, &community).await;
19045        let member_hex = member.keys.public_key().to_hex();
19046        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
19047        assert!(
19048            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
19049            "an ADMIN_ALL grant carries KICK"
19050        );
19051        assert!(view.banned.is_empty());
19052    }
19053
19054    // ── Pins (CORD-04 §7) — fold, authority, and the silent Admin widening ──
19055
19056    /// The full wire round trip: a real message pinned into a published
19057    /// edition, folded by the control follow, persisted, and read back proven.
19058    #[tokio::test]
19059    async fn a_pin_edition_folds_persists_and_reads_back() {
19060        let (_tmp, _guard, _owner) = init_test_db();
19061        let relay = MemoryRelay::new();
19062        let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
19063        let general = community.channels[0].id;
19064        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19065
19066        send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
19067        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19068        let opened = page
19069            .iter()
19070            .find_map(|f| match &f.event {
19071                ChatEvent::Message { opened, .. } => Some(opened.clone()),
19072                _ => None,
19073            })
19074            .expect("the sent message reads back");
19075
19076        let ch = community.channels[0].clone();
19077        let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
19078        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19079        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19080
19081        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19082        publish_control_edition(&relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19083        follow_control(&relay, &community).await.unwrap().updated;
19084
19085        let read = read_channel_pins(&community, &general).unwrap();
19086        assert!(!read.sealed);
19087        assert!(read.version >= 1, "the folded head persisted");
19088        assert_eq!(read.pins.len(), 1);
19089        assert_eq!(read.pins[0].content, "pin-worthy");
19090        assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
19091    }
19092
19093    #[tokio::test]
19094    async fn a_fresh_join_sees_pins_and_the_banlist_without_waiting_for_a_follow() {
19095        // The join's verification walk already folds the control plane, so pins and
19096        // the banlist must persist FROM THE JOIN — deferring them to the follow
19097        // worker's queued re-walk left a fresh member pins-blind (and rendering a
19098        // banned author) for the whole queue delay.
19099        let (bed, owner, member) = TestBed::new();
19100        bed.swap_to(&owner);
19101        let community = create_community(&bed.relay, "PinJoin", bed.relays.clone(), None).await.unwrap();
19102        let general = community.channels[0].id;
19103        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19104
19105        send_message(&bed.relay, &community, &general, "pin-worthy").await.unwrap();
19106        let page = fetch_channel(&bed.relay, &community, &general, 10).await.unwrap();
19107        let opened = page
19108            .iter()
19109            .find_map(|f| match &f.event {
19110                ChatEvent::Message { opened, .. } => Some(opened.clone()),
19111                _ => None,
19112            })
19113            .expect("the sent message reads back");
19114        let ch = community.channels[0].clone();
19115        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19116        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19117        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19118        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19119        publish_control_edition(&bed.relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19120        let spammer = Keys::generate().public_key().to_hex();
19121        set_banlist(&bed.relay, &community, std::slice::from_ref(&spammer)).await.unwrap();
19122        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
19123        let bundle_json = serde_json::to_string(&bundle).unwrap();
19124
19125        bed.swap_to(&member);
19126        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
19127        // Deliberately NO follow_control here — the join alone must suffice.
19128        let read = read_channel_pins(&joined, &general).unwrap();
19129        assert!(!read.sealed);
19130        assert_eq!(read.pins.len(), 1, "pins visible the instant the join returns");
19131        assert_eq!(read.pins[0].content, "pin-worthy");
19132        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
19133        let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19134        assert!(banlist.contains(&spammer), "the banlist is enforced from the join, not the first follow");
19135    }
19136
19137    /// CORD-04 §5: a pins edition from an author holding no PIN_MESSAGES never
19138    /// becomes the head — the fold's authority gate covers the new entity.
19139    #[tokio::test]
19140    async fn an_unauthorized_pin_edition_never_folds() {
19141        let (_tmp, _guard, _owner) = init_test_db();
19142        let relay = MemoryRelay::new();
19143        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
19144        let general = community.channels[0].id;
19145
19146        let rogue = Keys::generate();
19147        let group = control::ControlPlane::of(&community).write_group().unwrap();
19148        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19149        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
19150        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
19151        relay.publish(&wrap, &community.relays).await.unwrap();
19152
19153        follow_control(&relay, &community).await.unwrap().updated;
19154        let read = read_channel_pins(&community, &general).unwrap();
19155        assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
19156        assert!(read.pins.is_empty());
19157    }
19158
19159    /// The silent owner-side widening: a pre-pins Admin role (founding mask)
19160    /// gains PIN_MESSAGES as one edition of the same entity; idempotent after.
19161    #[tokio::test]
19162    async fn owner_silently_widens_a_legacy_admin_role() {
19163        let (_tmp, _guard, owner) = init_test_db();
19164        let relay = MemoryRelay::new();
19165        let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
19166        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19167        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
19168
19169        // An Admin role exactly as a pre-pins build published it.
19170        let legacy = Role {
19171            role_id: rid.clone(),
19172            name: "Admin".into(),
19173            position: 1,
19174            permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
19175            scope: RoleScope::Server,
19176            color: 0,
19177        };
19178        publish_role(&relay, &community, &owner, &legacy, 1).await;
19179        follow_control(&relay, &community).await.unwrap().updated;
19180
19181        assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
19182        follow_control(&relay, &community).await.unwrap().updated;
19183        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
19184        let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
19185        assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
19186        assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
19187
19188        // Second call: nothing left to widen.
19189        assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
19190    }
19191
19192    /// §7 deletion duty: the author-curator's own deleted message leaves the
19193    /// list as an immediate omitting edition.
19194    #[tokio::test]
19195    async fn the_deletion_duty_omits_a_pinned_message() {
19196        let (_tmp, _guard, _owner) = init_test_db();
19197        let relay = MemoryRelay::new();
19198        let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
19199        let general = community.channels[0].id;
19200        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19201
19202        let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
19203        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19204        let opened = page
19205            .iter()
19206            .find_map(|f| match &f.event {
19207                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19208                _ => None,
19209            })
19210            .unwrap();
19211        let ch = community.channels[0].clone();
19212        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19213        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19214        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19215        assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
19216
19217        // The author holds the bit (owner) → the duty publishes the omission at once.
19218        run_pin_duty(&relay, &ch_hex, &rumor_id, None).await.unwrap();
19219        let after = read_channel_pins(&community, &general).unwrap();
19220        assert!(after.pins.is_empty(), "the omitting edition landed");
19221        assert!(after.version >= 2, "a NEW edition, not a local erase");
19222    }
19223
19224    /// §7 edit duty: an edited pinned message gets its proof bundle refreshed,
19225    /// so keyless readers see the revision — and the duty is idempotent.
19226    #[tokio::test]
19227    async fn the_edit_duty_refreshes_a_pinned_proof() {
19228        let (_tmp, _guard, _owner) = init_test_db();
19229        let relay = MemoryRelay::new();
19230        let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
19231        let general = community.channels[0].id;
19232        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19233
19234        let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
19235        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19236        let opened = page
19237            .iter()
19238            .find_map(|f| match &f.event {
19239                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19240                _ => None,
19241            })
19242            .unwrap();
19243        let ch = community.channels[0].clone();
19244        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19245        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19246        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19247
19248        send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
19249        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19250        let edit_opened = page
19251            .iter()
19252            .find_map(|f| match &f.event {
19253                ChatEvent::Edit { opened, .. } => Some(opened.clone()),
19254                _ => None,
19255            })
19256            .expect("the edit reads back");
19257
19258        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone())).await.unwrap();
19259        let after = read_channel_pins(&community, &general).unwrap();
19260        assert_eq!(after.pins.len(), 1);
19261        assert_eq!(
19262            after.pins[0].content, "second thoughts",
19263            "the refreshed bundle proves the revision"
19264        );
19265        let v = after.version;
19266
19267        // Same revision again → monotonic guard, no new edition.
19268        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened)).await.unwrap();
19269        assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
19270    }
19271
19272    /// §7 author-side trigger: an own edit's relay echo is a dedup'd duplicate
19273    /// at ingest, so publish_chat's SEND echo must decide the duty itself —
19274    /// otherwise the author's own pin lags behind their message while every
19275    /// other client's follows ("the affected author acts at once").
19276    #[tokio::test]
19277    async fn an_own_edit_echo_carries_the_pin_duty_trigger() {
19278        let (_tmp, _guard, _owner) = init_test_db();
19279        let relay = MemoryRelay::new();
19280        let community = create_community(&relay, "OwnEdit", vec!["wss://r".into()], None).await.unwrap();
19281        let general = community.channels[0].id;
19282
19283        let rumor_id = send_message(&relay, &community, &general, "before").await.unwrap();
19284        send_edit(&relay, &community, &general, &rumor_id, "after").await.unwrap();
19285        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19286        let edit_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Edit { .. })).expect("the edit reads back").event;
19287        let msg_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Message { .. })).expect("the message reads back").event;
19288
19289        let updated = crate::community::v2::inbound::ChatPersist::Updated {
19290            message: crate::types::Message::default(),
19291            edit_event: None,
19292        };
19293        let (target_hex, opened) = own_echo_pin_duty(&updated, edit_event).expect("an applied own edit fires the duty");
19294        assert_eq!(target_hex, rumor_id, "the duty targets the EDITED message, not the edit rumor");
19295        assert_ne!(opened.rumor_id.to_hex(), rumor_id, "and carries the edit's own opened stream for the bundle");
19296
19297        // A fresh message echo is not a duty…
19298        let new = crate::community::v2::inbound::ChatPersist::New(crate::types::Message::default());
19299        assert!(own_echo_pin_duty(&new, msg_event).is_none());
19300        // …and neither is a reaction-shaped Updated on a non-edit event.
19301        assert!(own_echo_pin_duty(&updated, msg_event).is_none());
19302    }
19303
19304    /// §7 Rotator duty: a private-channel rotation republishes the Pin List
19305    /// sealed under the NEW epoch — a member who joins after the rotation
19306    /// (holding only the new key) must not read the channel's pins as dark.
19307    #[tokio::test]
19308    async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
19309        let (_tmp, _guard, _owner) = init_test_db();
19310        let relay = MemoryRelay::new();
19311        let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
19312        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19313        let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
19314        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19315        let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
19316
19317        let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
19318        let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
19319        let opened = page
19320            .iter()
19321            .find_map(|f| match &f.event {
19322                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19323                _ => None,
19324            })
19325            .unwrap();
19326        let ch = community.channel(&chan).unwrap().clone();
19327        let old_epoch = ch.epoch.0;
19328        let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
19329        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19330        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19331        let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19332
19333        // Rotate the channel away from a (never-granted) member.
19334        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19335        rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &[Keys::generate().public_key()])
19336            .await
19337            .unwrap();
19338
19339        // The stored head is a NEW edition, sealed under the NEW epoch.
19340        let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19341        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
19342        assert_eq!(
19343            parsed["epoch"].as_str().unwrap(),
19344            (old_epoch + 1).to_string(),
19345            "the reseal names the rotated epoch"
19346        );
19347        assert!(version > v_before, "a real edition, not a local rewrite");
19348
19349        // The rotator's own post-rotation view still verifies the pin.
19350        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19351        let read = read_channel_pins(&community, &chan).unwrap();
19352        assert!(!read.sealed);
19353        assert_eq!(read.pins.len(), 1);
19354        assert_eq!(read.pins[0].content, "sealed wisdom");
19355    }
19356
19357    /// The production ban-eraser: set_banlist must ECHO its published list
19358    /// into the local cache immediately. Before this, the cache moved only on
19359    /// a successful control fold — and a composing caller (ban = banlist →
19360    /// grant strip → refound) whose refound tripped re-read the stale list,
19361    /// so each of 19 real bans erased its predecessors.
19362    #[tokio::test]
19363    async fn a_published_banlist_echoes_locally_before_any_fold() {
19364        let (_tmp, _guard, _owner) = init_test_db();
19365        let relay = MemoryRelay::new();
19366        let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
19367        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19368        let spammer_a = "aa".repeat(32);
19369        let spammer_b = "bb".repeat(32);
19370
19371        // Ban A. NO fold runs — the cache must hold the publish regardless.
19372        set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
19373        assert_eq!(
19374            crate::db::community::get_community_banlist(&cid_hex).unwrap(),
19375            vec![spammer_a.clone()],
19376            "the publish echoes without waiting for a fold"
19377        );
19378
19379        // Ban B composes from the cache, exactly as the SDK does.
19380        let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19381        list.push(spammer_b.clone());
19382        set_banlist(&relay, &community, &list).await.unwrap();
19383        let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19384        assert!(
19385            held.contains(&spammer_a) && held.contains(&spammer_b),
19386            "sequential bans UNION; the second must not erase the first: {held:?}"
19387        );
19388
19389        // The wire agrees: a real fold confirms rather than regresses.
19390        follow_control(&relay, &community).await.unwrap().updated;
19391        let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19392        assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
19393    }
19394}