Skip to main content

vector_core/community/v2/
realtime.rs

1//! v2 realtime — the `authors`-based live subscription + dispatch.
2//!
3//! v2 addresses planes by their group pubkey (CORD-01), not v1's `#z` tag, so
4//! the subscription is `{kinds:[1059,21059], authors:[…plane pubkeys…]}`. A
5//! received event is routed to the owning community and handed to the shared
6//! [`inbound::dispatch_wrap`], which fires the protocol-agnostic
7//! `InboundEventHandler` the SDK's `on_message` consumes.
8//!
9//! The kind-1059 dispatch rule (CLAUDE.md A4): the listen loop tries this v2
10//! path for events on the v2 subscription; DM gift wraps and 3313 Direct Invites
11//! (both `#p=me`) stay on the DM subscription — the author-set here never
12//! includes an identity key, so the two never collide.
13
14use std::collections::HashSet;
15use std::sync::{Arc, LazyLock, Mutex as StdMutex};
16
17use nostr_sdk::prelude::{Client, Event, Filter, Kind, PublicKey, RelayStatus, RelayUrl, SubscriptionId};
18use tokio::sync::mpsc::UnboundedSender;
19use tokio::sync::Mutex;
20
21use super::community::CommunityV2;
22use super::stream;
23use super::{derive, inbound};
24use crate::community::{CommunityId, ConcordProtocol, Epoch};
25use crate::event_handler::InboundEventHandler;
26use crate::state::SessionGuard;
27use crate::ClientRelayExt;
28
29/// The targeted subscription id (streams on desktop).
30static V2_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
31/// The pool-wide subscription id (the path that streams on Android).
32static V2_POOLWIDE_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
33/// The current author-set (sorted hex) — an unchanged set skips a churny
34/// unsubscribe+resubscribe, exactly like v1's `COMMUNITY_SUB_SET`.
35static V2_SUB_SET: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
36/// Outer-wrap ids already dispatched, so the handler fires EXACTLY ONCE per
37/// message. The relay pool delivers the same wrap under both the targeted and
38/// pool-wide subs and from every relay independently, so without this a bot's
39/// `on_message` (and its reply) would run several times per message — the v1
40/// community path and the DM path dedup by outer id for the same reason. Cleared
41/// on session swap; coarsely bounded (a message's duplicates all arrive within a
42/// short window, so a recent-set suffices).
43static V2_SEEN_WRAPS: LazyLock<Mutex<HashSet<[u8; 32]>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
44/// Bound on [`V2_SEEN_WRAPS`] before a coarse flush.
45const SEEN_WRAPS_CAP: usize = 8192;
46/// The per-community follow QUEUE. dispatch, boot catch-up, reconnect, and manual
47/// sync all just [`enqueue_follow`] — non-blocking + coalesced. A single spawned
48/// worker ([`spawn_follow_worker`]) drains it and runs one combined rekey+control
49/// follow per community at a time, so two triggers can never concurrently
50/// whole-row-save and clobber each other. This replaces the old gate/rerun/
51/// spawn-vs-await machinery: an enqueue never blocks its caller, and a junk-wrap
52/// flood coalesces to at most one queued + one running follow per community.
53/// Reset on session swap (the worker exits when its `SessionGuard` invalidates or
54/// its channel closes).
55static V2_FOLLOW_TX: LazyLock<StdMutex<Option<UnboundedSender<CommunityId>>>> = LazyLock::new(|| StdMutex::new(None));
56/// Community ids currently queued or processing — coalesces a burst to one follow.
57static V2_FOLLOW_PENDING: LazyLock<StdMutex<HashSet<[u8; 32]>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
58/// Per-community follow serialization, shared by the queue worker AND the inline
59/// (headless) follow path. The worker-vs-inline CHOICE is a benign race
60/// (`follow_worker_running` is check-then-act; a worker can spawn right after a
61/// `false`), so correctness can't ride on it: whichever path runs, the follow body
62/// executes under this lock and two follows of one community can never interleave
63/// their whole-row saves. Bounded by the held-community count; reset on swap.
64static V2_FOLLOW_LOCKS: LazyLock<StdMutex<std::collections::HashMap<[u8; 32], Arc<Mutex<()>>>>> =
65    LazyLock::new(|| StdMutex::new(std::collections::HashMap::new()));
66
67/// The follow lock for one community (created on first use).
68pub(crate) fn follow_lock(id: &CommunityId) -> Arc<Mutex<()>> {
69    V2_FOLLOW_LOCKS.lock().unwrap().entry(id.0).or_default().clone()
70}
71
72/// The author set the live v2 subscription CURRENTLY carries (sorted hex).
73/// Diagnostics: comparing this against the freshly-derived plane authors is the
74/// one-glance test for "did a rotation leave this client subscribed to a dead
75/// epoch" — the failure that otherwise only shows up as silent missing events.
76pub async fn subscribed_author_set() -> Vec<String> {
77    V2_SUB_SET.lock().await.clone()
78}
79
80/// Diagnostics: run the three follow stages once for `id` UNDER the follow lock
81/// (so it can't race the worker into a whole-row clobber) and return each
82/// stage's outcome as a display string. Reloads freshly-persisted state between
83/// stages, exactly like [`follow_community`]. Mutating — same writes a live
84/// follow makes; the lock serializes it against the worker.
85#[cfg(debug_assertions)]
86pub async fn debug_run_follow_stages(id: &CommunityId, session: &SessionGuard) -> (String, String, String) {
87    let lock = follow_lock(id);
88    let _guard = lock.lock().await;
89    let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
90
91    let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
92        return ("community gone".into(), "-".into(), "-".into());
93    };
94    let rekeys = match super::service::follow_rekeys(&transport, &c, session).await {
95        Ok(f) => format!(
96            "Ok(updated={} self_removed={} dissolved={})",
97            f.updated.as_ref().map(|u| format!("root_e{}", u.root_epoch.0)).unwrap_or_else(|| "no".into()),
98            f.self_removed, f.dissolved
99        ),
100        Err(e) => format!("ERR: {e}"),
101    };
102    let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
103        return (rekeys, "community gone".into(), "-".into());
104    };
105    let control = match super::service::follow_control(&transport, &c, session).await {
106        Ok(v) => format!("Ok(changed={})", v.is_some()),
107        Err(e) => format!("ERR: {e}"),
108    };
109    let Ok(Some(c)) = crate::db::community::load_community_v2(id) else {
110        return (rekeys, control, "community gone".into());
111    };
112    let guestbook = match super::service::sync_guestbook(&transport, &c, session).await {
113        Ok(fresh) => format!("Ok(fresh={})", fresh.len()),
114        Err(e) => format!("ERR: {e}"),
115    };
116    (rekeys, control, guestbook)
117}
118
119pub async fn subscription_id() -> Option<SubscriptionId> {
120    V2_SUB_ID.lock().await.clone()
121}
122
123pub async fn poolwide_subscription_id() -> Option<SubscriptionId> {
124    V2_POOLWIDE_SUB_ID.lock().await.clone()
125}
126
127/// Clear the v2 realtime state on a session reset (called from `swap_session`
128/// alongside v1's clear), so a stale sub id / author-set can't leak across accounts.
129pub async fn clear() {
130    *V2_SUB_ID.lock().await = None;
131    *V2_POOLWIDE_SUB_ID.lock().await = None;
132    V2_SUB_SET.lock().await.clear();
133    V2_SEEN_WRAPS.lock().await.clear();
134    // Drop the queue sender so the worker's channel closes and it exits (its
135    // SessionGuard also invalidates); the next login spawns a fresh worker.
136    *V2_FOLLOW_TX.lock().unwrap() = None;
137    V2_FOLLOW_PENDING.lock().unwrap().clear();
138    V2_FOLLOW_LOCKS.lock().unwrap().clear();
139    // Account A's stream keys must not keep authenticating (or answering relay
140    // challenges) once account B is live.
141    super::streamauth::clear();
142}
143
144/// Every plane pubkey a set of v2 communities publishes under that
145/// [`inbound::dispatch_wrap`] handles — the subscription author-set. Per
146/// community: the guestbook, the control plane, and each channel's current
147/// Chat-Plane address. Pure + deterministic (deduped, sorted) — the testable core.
148///
149/// The **control plane** rides here so a long-running bot follows metadata +
150/// public-channel edits live ([`super::service::follow_control`] re-folds on a
151/// recognized wrap), and the next-epoch rekey planes ride via [`rekey_authors`]
152/// (each subscribed author has its `dispatch_wrap` arm — never one without the
153/// other).
154pub fn plane_authors(communities: &[CommunityV2]) -> Vec<PublicKey> {
155    let mut out = Vec::new();
156    for c in communities {
157        out.push(derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk());
158        out.push(control_author(c));
159        // The dissolved plane (CORD-02 §9) — so a mid-session dissolution seals live.
160        out.push(super::derive::dissolved_group_key(c.id()).pk());
161        for ch in &c.channels {
162            // A KEYLESS private channel has no readable chat plane — channel_secret's
163            // root fallback would subscribe the PUBLIC plane for it. Its rekey plane
164            // (below) is still watched, which is how its key arrives.
165            if ch.private && ch.key.is_none() {
166                continue;
167            }
168            let (secret, epoch) = c.channel_secret(ch);
169            out.push(derive::channel_group_key(&secret, &ch.id, epoch).pk());
170        }
171        out.extend(rekey_authors(c));
172    }
173    out.sort_by_key(|p| p.to_hex());
174    out.dedup();
175    out
176}
177
178/// This community's Control Plane address at the current root epoch — the single
179/// source of truth shared by [`plane_authors`] (subscribe) and
180/// [`inbound::dispatch_wrap`] (recognize) so the two can't drift.
181pub(crate) fn control_author(c: &CommunityV2) -> PublicKey {
182    derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()
183}
184
185/// The next-epoch rekey plane addresses for a community: the base rotation
186/// (`root_epoch + 1`) and each Private channel's rotation (`channel epoch + 1`),
187/// both under the CURRENT community_root. This is the single source of truth for
188/// which rekey wraps we subscribe AND recognize (`inbound::dispatch_wrap` calls
189/// it), so the two can never drift. A Public channel has no independent rotation
190/// (it rides the base), so only Private channels contribute a channel address.
191pub(crate) fn rekey_authors(c: &CommunityV2) -> Vec<PublicKey> {
192    // saturating: a bundle's epoch isn't covered by the community_id commitment, so
193    // it's attacker-influenced — never let `epoch + 1` overflow (a u64::MAX epoch
194    // just yields a dead address, never a panic).
195    let mut out = vec![derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0.saturating_add(1))).pk()];
196    for ch in &c.channels {
197        if ch.private {
198            out.push(derive::channel_rekey_group_key(&c.community_root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).pk());
199        }
200    }
201    out
202}
203
204/// Load every locally-held, LIVE **v2** community (dispatching each id by its
205/// stored protocol). The realtime layer folds these into the subscription +
206/// routing, so excluding a DISSOLVED community here is what enforces CORD-02 §9
207/// on the receive side: its chat/control/rekey planes stop being subscribed and
208/// an arriving wrap for it is `NotOurs` (dropped, never honored). Held keys still
209/// open old history through the explicit read paths — this only stops NEW events.
210pub fn load_held_v2() -> Vec<CommunityV2> {
211    let ids = crate::db::community::list_community_ids().unwrap_or_default();
212    ids.iter()
213        .filter(|id| matches!(crate::db::community::community_protocol(id).ok().flatten(), Some(ConcordProtocol::V2)))
214        .filter(|id| !crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false))
215        .filter_map(|id| crate::db::community::load_community_v2(id).ok().flatten())
216        .collect()
217}
218
219/// Refresh the v2 subscription for the held communities: register
220/// `{kinds:[1059,21059], authors:[…]}` on their relays (targeted + pool-wide,
221/// mirroring v1). Idempotent on an unchanged author-set.
222pub async fn refresh_subscription(client: &Client) {
223    // Phase 1, LOCK-FREE: make sure the community relays are added + connected —
224    // the slow part (a connect wait of up to ~6s). Holding the sub locks across
225    // this stalled every concurrent dispatch/refresh behind one caller's connect.
226    {
227        let communities = load_held_v2();
228        let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
229        relays.sort();
230        relays.dedup();
231        if !relays.is_empty() {
232            // Community relays ride GOSSIP|PING (warm but excluded from pool-wide DM ops).
233            for r in &relays {
234                let _ = client.add_managed_relay(r.as_str()).capabilities(crate::community_relay_capabilities()).await;
235            }
236            client.connect().await;
237            // Wait briefly for at least one relay to actually connect (a subscribe
238            // against a still-connecting relay silently fails to register — same
239            // trap as v1).
240            let wanted: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
241            for _ in 0..24 {
242                let pool = client.relays().all().await;
243                if wanted.iter().any(|u| pool.get(u).map(|r| r.status() == RelayStatus::Connected).unwrap_or(false)) {
244                    break;
245                }
246                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
247            }
248            // AUTH-gating relays serve the planes only to a stream-authenticated
249            // connection, and a live subscription isn't auto-retried after the gate.
250            // Register every held plane's key + prime the connection auth (a cheap
251            // gated fetch the responder answers) so the subscription below streams.
252            for c in &communities {
253                super::streamauth::register_community(c);
254            }
255            super::streamauth::prime_auth(client, &relays).await;
256        }
257    }
258
259    // Phase 2, LOCKED + bounded (no connect waits): RE-snapshot the held state
260    // INSIDE the sub locks — the follow worker runs concurrently and may have just
261    // adopted a rotation, so the LAST locker must read the freshest persisted
262    // authors. An out-of-lock read lets a stale caller commit an old author-set
263    // over a fresh one and silently mute a rotated community. (A community whose
264    // relays appeared between the phases subscribes now and connects on the next
265    // refresh — the follow that discovers it always triggers one.)
266    let mut sub_guard = V2_SUB_ID.lock().await;
267    let mut set_guard = V2_SUB_SET.lock().await;
268
269    let communities = load_held_v2();
270    let authors = plane_authors(&communities);
271    let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
272    relays.sort();
273    relays.dedup();
274
275    let mut new_set: Vec<String> = authors.iter().map(|p| p.to_hex()).collect();
276    new_set.sort();
277
278    // Unchanged-set fast path — but only when BOTH subs actually registered (a
279    // failed pool-wide subscribe would otherwise stay absent until the author-set
280    // changes, and Android streams via the pool-wide path).
281    if sub_guard.is_some() && *set_guard == new_set && (authors.is_empty() || V2_POOLWIDE_SUB_ID.lock().await.is_some()) {
282        return; // the pool re-applies the live subs across reconnects.
283    }
284    if let Some(old) = sub_guard.take() {
285        let _ = client.unsubscribe(&old).await;
286    }
287    *set_guard = new_set;
288
289    if authors.is_empty() {
290        if let Some(old_pw) = V2_POOLWIDE_SUB_ID.lock().await.take() {
291            let _ = client.unsubscribe(&old_pw).await;
292        }
293        return;
294    }
295
296    let filter = Filter::new()
297        .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
298        .authors(authors)
299        .limit(0);
300
301    {
302        let mut pw = V2_POOLWIDE_SUB_ID.lock().await;
303        if let Some(old) = pw.take() {
304            let _ = client.unsubscribe(&old).await;
305        }
306        if let Ok(out) = client.subscribe(filter.clone()).await {
307            *pw = Some(out.value);
308        }
309    }
310    if let Ok(out) = client
311        .subscribe(nostr_sdk::prelude::ReqTarget::manual(
312            relays.iter().cloned().map(|u| (u, vec![filter.clone()])),
313        ))
314        .await
315    {
316        *sub_guard = Some(out.value);
317    }
318}
319
320/// Re-send the CURRENT v2 subscriptions (same ids) to ONE relay. An AUTH-gating
321/// relay CLOSEs a sub REQ that raced ahead of the stream AUTHs on a fresh
322/// connection, and it never re-challenges once the connection is authenticated —
323/// so the moment the streams finish authenticating is exactly when the subs must
324/// be re-sent. nostr-sdk re-sends them only when ITS OWN auth completes (it
325/// can't see ours), which usually wins by socket ordering; this makes the heal
326/// deterministic and independent of that internal. Same-id REQs are idempotent.
327pub(crate) async fn resubscribe_relay(client: &Client, relay: &RelayUrl) {
328    let targeted = V2_SUB_ID.lock().await.clone();
329    let poolwide = V2_POOLWIDE_SUB_ID.lock().await.clone();
330    if targeted.is_none() && poolwide.is_none() {
331        return; // nothing subscribed yet — the first refresh registers on an authed socket.
332    }
333    let communities = load_held_v2();
334    let authors = plane_authors(&communities);
335    if authors.is_empty() {
336        return;
337    }
338    let filter = Filter::new()
339        .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
340        .authors(authors)
341        .limit(0);
342    for id in [targeted, poolwide].into_iter().flatten() {
343        let _ = client
344            .subscribe(nostr_sdk::prelude::ReqTarget::single(relay.clone(), [filter.clone()]))
345            .with_id(id)
346            .await;
347    }
348}
349
350/// Route an arriving v2 wrap: find the held community whose plane it opens under
351/// and fire the matching handler callback (via the shared bridge). Persistence to
352/// the local DB is deferred (bots deliver via the callback; GUI history is v1 for
353/// now). `session` gates against a mid-flight account swap.
354pub async fn dispatch_event(session: &SessionGuard, event: Event, handler: Arc<dyn InboundEventHandler>) {
355    let Some(my_pk) = crate::my_public_key() else {
356        return;
357    };
358    if !session.is_valid() {
359        return;
360    }
361    // Fire EXACTLY ONCE per wrap: the pool re-delivers the same event under both
362    // subs and from every relay. `insert` returns false if already dispatched.
363    {
364        let mut seen = V2_SEEN_WRAPS.lock().await;
365        if !seen.insert(event.id.to_bytes()) {
366            return;
367        }
368        if seen.len() > SEEN_WRAPS_CAP {
369            let keep = event.id.to_bytes();
370            seen.clear();
371            seen.insert(keep);
372        }
373    }
374    let communities = load_held_v2();
375    for c in &communities {
376        match inbound::dispatch_wrap(&event, c, &my_pk, &*handler) {
377            inbound::DispatchedV2::NotOurs => continue,
378            // A control OR a rekey wrap: just enqueue a follow for this community.
379            // Non-blocking + coalesced — the single follow worker serializes control
380            // and rekey per community (no concurrent whole-row clobber) off this hot
381            // path, so a junk-wrap flood can't head-of-line-block the notification loop.
382            inbound::DispatchedV2::Control { .. } | inbound::DispatchedV2::Rekey { .. } => {
383                enqueue_follow(c.id());
384                return;
385            }
386            inbound::DispatchedV2::Dissolved { community_id } => {
387                // Death wins (CORD-02 §9): seal read-only + surface the grave, ONCE
388                // (a re-wrapped tombstone with a fresh outer id must not re-fire the
389                // handler). The next load_held_v2 excludes it, so its planes also
390                // stop being subscribed + routed.
391                if crate::db::community::set_community_dissolved(&community_id).unwrap_or(false) {
392                    handler.on_community_dissolved(&community_id);
393                    if let Some(client) = crate::state::nostr_client() {
394                        refresh_subscription(&client).await;
395                    }
396                }
397                return;
398            }
399            // A chat event, opened but NOT yet applied: persist first (dedup by inner
400            // id + the author-scoped edit/delete checks), then fire the callback from
401            // the outcome — v1's exact model. A re-wrapped duplicate (any keyholder
402            // can re-seal a signed rumor into a fresh 1059), the relay echo of our
403            // own send, or a forged edit/delete yields no outcome and re-fires
404            // nothing.
405            inbound::DispatchedV2::Chat { channel_id, event } => {
406                if !session.is_valid() {
407                    return;
408                }
409                match inbound::persist_chat_event(&event, &channel_id, &my_pk, session).await {
410                    Some(inbound::ChatPersist::New(message)) => handler.on_community_message(&channel_id, &message, true),
411                    // A reaction or an edit: the folded TARGET row (its id is the
412                    // target's) — the same payload v1 hands this callback. Both come
413                    // back out of STATE, whose compact form drops the quoted
414                    // attachment's extension, so re-resolve before rendering.
415                    Some(inbound::ChatPersist::Updated { mut message, .. }) => {
416                        let _ = crate::db::events::populate_reply_context(&mut message).await;
417                        handler.on_community_update(&channel_id, &message.id, &message);
418                    }
419                    // An un-react re-renders the PARENT (its chips changed) — the
420                    // same surface a landed reaction drives.
421                    Some(inbound::ChatPersist::ReactionRemoved { mut message, .. }) => {
422                        let _ = crate::db::events::populate_reply_context(&mut message).await;
423                        handler.on_community_update(&channel_id, &message.id, &message);
424                    }
425                    Some(inbound::ChatPersist::Removed(target_id)) => handler.on_community_removed(&channel_id, &target_id),
426                    None => {}
427                }
428                return;
429            }
430            inbound::DispatchedV2::Presence { .. } => {
431                // Live membership motion: fold it into the persisted Guestbook so
432                // the memberlist stays a local read (the presence callback already
433                // fired inline). Reopen here — the dispatcher stays pure — and
434                // refresh the overview when it lands.
435                if !session.is_valid() {
436                    return;
437                }
438                let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
439                if let Ok(opened) = super::stream::open_wrap(&event, &gb) {
440                    if let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) {
441                        let changed = super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false);
442                        if changed && session.is_valid() {
443                            handler.on_community_refreshed(&crate::simd::hex::bytes_to_hex_32(&c.id().0));
444                        }
445                    }
446                }
447                return;
448            }
449            inbound::DispatchedV2::Kick { target } => {
450                if !session.is_valid() {
451                    return;
452                }
453                let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
454                let Ok(opened) = super::stream::open_wrap(&event, &gb) else { return };
455                let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) else { return };
456                if !super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false) {
457                    return;
458                }
459                if !session.is_valid() {
460                    return;
461                }
462                let community_id = crate::simd::hex::bytes_to_hex_32(&c.id().0);
463                // Self-removal rides the AUTHORIZED fold, never the wrap: the store takes
464                // any Kick rumor, so acting on arrival would let any member evict anyone.
465                // Fail-closed — a kick our roster can't justify leaves us in place.
466                //
467                // The COALESCE verdict, not the memberlist: on a rejoin the store starts
468                // empty while the control fold has already re-derived our old ban mark, so
469                // the memberlist legitimately excludes us for the window before the
470                // Guestbook catches up. A stale Kick landing in that window would read as
471                // an authorized eviction. Coalescing asks only what our latest authorized
472                // entry is, so a fresh Join beats the old Kick and an empty store decides
473                // nothing.
474                let evicted = my_pk == target && super::service::stored_kick_verdict(c, &my_pk);
475                if evicted {
476                    // WARN, not info: `log_info!` is compiled out in release, and a
477                    // self-eviction is exactly the destructive, hard-to-diagnose event
478                    // that must leave a trace on a shipped build.
479                    crate::log_warn!(
480                        "[v2:teardown {}] KICK: the authorized guestbook fold rules us kicked",
481                        &community_id[..8.min(community_id.len())]
482                    );
483                    handler.on_community_self_removed(&community_id);
484                } else {
485                    crate::log_debug!(
486                        "[v2:kick {}] declined: target={} is not ruled kicked by the fold",
487                        &community_id[..8.min(community_id.len())], &target.to_hex()[..8]
488                    );
489                    handler.on_community_refreshed(&community_id);
490                }
491                return;
492            }
493            _ => return, // typing (and non-surfaced guestbook kinds) handled inline by the dispatcher.
494        }
495    }
496}
497
498/// Whether a live follow worker is draining the queue (a `listen()` is running).
499/// Headless callers use this to run a follow inline instead of enqueueing into
500/// the void.
501pub fn follow_worker_running() -> bool {
502    V2_FOLLOW_TX.lock().unwrap().as_ref().map(|tx| !tx.is_closed()).unwrap_or(false)
503}
504
505/// Queue a follow for `id` — NON-BLOCKING + coalesced. A burst (or a junk-wrap
506/// flood) collapses to at most one queued + one running follow per community. A
507/// no-op if no worker is running (no live `listen()`). Callers: dispatch,
508/// boot/reconnect catch-up, manual sync — none of them block or touch a lock for
509/// longer than the enqueue.
510pub fn enqueue_follow(id: &CommunityId) {
511    let mut pending = V2_FOLLOW_PENDING.lock().unwrap();
512    if !pending.insert(id.0) {
513        return; // already queued or processing — coalesce.
514    }
515    match V2_FOLLOW_TX.lock().unwrap().as_ref() {
516        Some(tx) if tx.send(*id).is_ok() => {}
517        _ => {
518            // No worker yet — PARK the id in the pending set instead of
519            // dropping it; spawn_follow_worker drains parked ids into its
520            // fresh queue. The boot sweep's enqueues race notifs' worker
521            // spawn (they fire the moment init completes), so a pre-worker
522            // enqueue must defer, never silently vanish — a dropped boot
523            // refold leaves an offline-rotated community wedged at its old
524            // epoch until some live event happens to trigger a dispatch.
525        }
526    }
527}
528
529/// Spawn the single follow worker for this session. Installs the queue sender and
530/// drains it, running one combined follow per community at a time. Replacing the
531/// sender (a re-`listen()`) or [`clear`] (a swap) closes the old channel so the old
532/// worker exits; the captured `SessionGuard` also stops it. Idempotent per session.
533pub fn spawn_follow_worker(handler: Arc<dyn InboundEventHandler>) {
534    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
535    // Re-send every pending id into the fresh queue: parked pre-worker
536    // enqueues AND anything a replaced worker's dropped channel still owed.
537    // Entries stay in the set — the worker removes each as it starts
538    // processing, preserving the coalescing invariant.
539    {
540        let pending = V2_FOLLOW_PENDING.lock().unwrap();
541        for id in pending.iter() {
542            let _ = tx.send(CommunityId(*id));
543        }
544    }
545    *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
546    let session = SessionGuard::capture();
547    tokio::spawn(async move {
548        while let Some(id) = rx.recv().await {
549            if !session.is_valid() {
550                break;
551            }
552            // Remove from pending BEFORE running, so a trigger arriving DURING the
553            // follow re-enqueues (and is processed after) rather than being lost.
554            V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
555            follow_community(&session, &id, &*handler).await;
556        }
557    });
558}
559
560/// One combined rekey-then-control follow for a community, each pass against the
561/// FRESHLY-RELOADED persisted state (never a stale clone, so the two planes can't
562/// lose each other's writes). Rekey runs first: a base adopt moves the control
563/// address, and a self-removal tears the community down (skipping control). No-op
564/// without a live client — unit tests drive `service::follow_control` /
565/// `follow_rekeys` directly.
566async fn follow_community(session: &SessionGuard, id: &CommunityId, handler: &dyn InboundEventHandler) {
567    let Some(client) = crate::state::nostr_client() else {
568        return;
569    };
570    // Serialize against an inline (headless) follow of the same community — the
571    // queue only serializes triggers routed THROUGH it.
572    let lock = follow_lock(id);
573    let _guard = lock.lock().await;
574    let community_id = crate::simd::hex::bytes_to_hex_32(&id.0);
575    let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
576
577    // Rekey first (fresh DB state).
578    let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
579        return; // community gone (left / removed).
580    };
581    match super::service::follow_rekeys(&transport, &current, session).await {
582        // A tombstone surfaced during catch-up (an offline member learning of a
583        // death) — the flag is set; seal + surface, and stop following.
584        Ok(follow) if follow.dissolved => {
585            if !session.is_valid() {
586                return;
587            }
588            crate::log_warn!("[v2:teardown {}] DISSOLVED tombstone", &community_id[..8.min(community_id.len())]);
589            handler.on_community_dissolved(&community_id);
590            return;
591        }
592        Ok(follow) if follow.self_removed => {
593            if !session.is_valid() {
594                return;
595            }
596            crate::log_warn!("[v2:teardown {}] REKEY EXCLUSION: an authorized rotation left us out", &community_id[..8.min(community_id.len())]);
597            let _ = crate::db::community::delete_community(&community_id);
598            refresh_subscription(&client).await;
599            handler.on_community_self_removed(&community_id);
600            return;
601        }
602        Ok(follow) if follow.updated.is_some() => {
603            if !session.is_valid() {
604                return;
605            }
606            refresh_subscription(&client).await;
607            handler.on_community_refreshed(&community_id);
608        }
609        Ok(_) => {}
610        Err(e) => {
611            // Surfaced, not swallowed: with EOSE-verified fetches an AUTH-gated
612            // or dead rekey plane now reports here instead of masquerading as
613            // "no rotation" — the exact signature of an epoch wedge.
614            crate::log_warn!("[v2:follow {}] rekey follow failed (will retry on next trigger): {}", &community_id[..8.min(community_id.len())], e);
615            return;
616        }
617    }
618
619    // Control second, on the (possibly new-root) freshly-reloaded state.
620    let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
621        return;
622    };
623    let control_changed = matches!(
624        super::service::follow_control(&transport, &current, session).await,
625        Ok(Some(_))
626    );
627    if !session.is_valid() {
628        return;
629    }
630    // Re-judge parked key vends on EVERY pass, not only when the fold moved. A
631    // vend that lands AFTER its Grant folded has nothing left to change the
632    // control plane, so gating this on `changed` strands it until some unrelated
633    // edition happens by (CORD-03 "delivered on grant").
634    if let Ok(Some(folded)) = crate::db::community::load_community_v2(id) {
635        let keyed = super::service::absorb_parked_channel_keys(&folded, session);
636        if !keyed.is_empty() {
637            crate::log_info!(
638                "[v2:follow {}] adopted {} vended private-channel key(s)",
639                &community_id[..8.min(community_id.len())],
640                keyed.len()
641            );
642            // A newly-keyed channel adds its chat plane to the readable set, and
643            // the live subscription is addressed BY that set — without a rebuild
644            // we hold the key and still never hear the channel. Independent of
645            // `control_changed`: adopting a parked vend changes no control state.
646            refresh_subscription(&client).await;
647        }
648        for ch in keyed {
649            let hex = crate::simd::hex::bytes_to_hex_32(&ch.0);
650            // Read what was said while we were locked out. The live subscription
651            // only carries what arrives NEXT, so without this a member granted
652            // access walks into a room that looks empty.
653            let _ = crate::VectorCore::v2_backfill_channel(
654                id, &hex, 50, 2, None,
655                crate::community::transport::Evidence::Fast, 12,
656            )
657            .await;
658            if !session.is_valid() {
659                return;
660            }
661            handler.on_channel_keyed(&community_id, &hex);
662        }
663    }
664    if control_changed {
665        refresh_subscription(&client).await;
666        handler.on_community_refreshed(&community_id);
667        // A control change can reveal rekey work that predates it — a just-announced
668        // private channel's key crate is already sitting on its rekey plane (the key
669        // ships BEFORE the vsk-2), and this pass's rekey walk ran before the channel
670        // existed. Queue one more pass; it coalesces and converges (an unchanged
671        // control fold doesn't re-queue).
672        enqueue_follow(id);
673    }
674
675    // Banned by the banlist we just folded → self-remove NOW, not when the Refounding
676    // eventually lands. A Ban composes three layers (CORD-04 §6) and their guarantees
677    // arrive at different speeds: the Banlist edition is instant, the Refounding is
678    // "heavy and asynchronous" by design. Waiting on the rotation to notice our own
679    // removal left the target sitting in a community that had already dropped them from
680    // its member count — silenced, still looking joined. Checked unconditionally: a
681    // banlist-only change folds no document, so `follow_control` returns None for it.
682    // v1 has done this since it shipped (`am_i_banned` in its own realtime refresh).
683    if let Some(me) = crate::my_public_key() {
684        if crate::db::community::is_author_banned(&community_id, &me) {
685            if !session.is_valid() {
686                return;
687            }
688            crate::log_warn!("[v2:teardown {}] SELF-BAN: our npub is in the folded banlist", &community_id[..8.min(community_id.len())]);
689            handler.on_community_self_removed(&community_id);
690            return;
691        }
692    }
693
694    // Guestbook third: catch the membership store up from its cursor. Boot and
695    // reconnect land here through this same queue, so the memberlist is a local
696    // read by the time any panel asks — and every join/leave the catch-up folds
697    // surfaces as a presence line (real-rumor-id keyed, so a line each path also
698    // saw live inserts exactly once).
699    let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
700        return;
701    };
702    if let Ok(fresh) = super::service::sync_guestbook(&transport, &current, session).await {
703        if fresh.is_empty() || !session.is_valid() {
704            return;
705        }
706        surface_presence(&current, &fresh, handler);
707        handler.on_community_refreshed(&community_id);
708    }
709}
710
711/// Fire the presence-line surface for freshly-folded guestbook events — the
712/// catch-up twin of the live dispatch (same handler, same real-id dedup key,
713/// same banned-author drop). Kicks/snapshots shape the memberlist, not the feed.
714fn surface_presence(
715    community: &CommunityV2,
716    fresh: &[super::guestbook::GuestbookEvent],
717    handler: &dyn InboundEventHandler,
718) {
719    use super::guestbook::GuestbookEntry;
720    use nostr_sdk::prelude::ToBech32;
721    let Some(primary) = community.primary_channel() else {
722        return;
723    };
724    let chat_id = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
725    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
726    let banned = crate::db::community::banned_set(&cid_hex);
727    for ev in fresh {
728        let (member, joined, at_ms, invited_by) = match &ev.entry {
729            GuestbookEntry::Join { member, at_ms, invited_by } => (member, true, *at_ms, invited_by.clone()),
730            GuestbookEntry::Leave { member, at_ms } => (member, false, *at_ms, None),
731            GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => continue,
732        };
733        if banned.contains(&member.to_bytes()) {
734            continue;
735        }
736        let Ok(npub) = member.to_bech32();
737        let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
738        let (by, label) = match &invited_by {
739            Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
740            None => (None, None),
741        };
742        handler.on_community_presence(&chat_id, &npub, joined, &event_id, at_ms / 1000, by, label);
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use super::super::control::{genesis, CommunityMetadata};
750    use crate::community::Epoch;
751    use nostr_sdk::prelude::Keys;
752
753    fn a_community(name: &str) -> CommunityV2 {
754        let owner = Keys::generate();
755        let g = genesis(&owner, CommunityMetadata { name: name.into(), ..Default::default() }, 1_000).unwrap();
756        CommunityV2::from_genesis(&g, name, None, vec!["wss://r".into()], 0)
757    }
758
759    #[test]
760    fn plane_authors_covers_the_dispatched_planes_only() {
761        let c = a_community("A");
762        let authors = plane_authors(std::slice::from_ref(&c));
763
764        // Subscribed: the guestbook, the control plane, and the one public channel
765        // (the planes dispatch_wrap handles). Exactly those three.
766        let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk();
767        let control = derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk();
768        let general = {
769            let (s, e) = c.channel_secret(&c.channels[0]);
770            derive::channel_group_key(&s, &c.channels[0].id, e).pk()
771        };
772        // Plus the next base-rekey address (rekey-follow). A public channel has no
773        // independent rotation, so #general contributes no channel-rekey address.
774        let next_base = derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(1)).pk();
775        // Plus the dissolved plane (CORD-02 §9), so a live dissolution is detected.
776        let dissolved = derive::dissolved_group_key(c.id()).pk();
777        assert!(
778            authors.contains(&gb)
779                && authors.contains(&control)
780                && authors.contains(&general)
781                && authors.contains(&next_base)
782                && authors.contains(&dissolved)
783        );
784        assert_eq!(authors.len(), 5, "guestbook + control + dissolved + chat + base-rekey planes are subscribed");
785    }
786
787    #[test]
788    fn plane_authors_is_deterministic_deduped_and_multi_community() {
789        let a = a_community("A");
790        let b = a_community("B");
791        let one = plane_authors(std::slice::from_ref(&a));
792        // Re-running over the same community is byte-identical (deterministic).
793        assert_eq!(plane_authors(std::slice::from_ref(&a)), one);
794        // Two distinct communities' planes are all present, none dropped.
795        let two = plane_authors(&[a.clone(), b.clone()]);
796        assert_eq!(two.len(), one.len() * 2);
797        // Order-independent: reversing the input yields the identical sorted set.
798        assert_eq!(plane_authors(&[b, a]), two);
799    }
800
801    #[tokio::test]
802    async fn dispatch_event_routes_a_v2_message_to_the_handler() {
803        use crate::community::transport::memory::MemoryRelay;
804        use crate::community::transport::{Query, Transport};
805        use crate::types::Message;
806        use std::sync::Mutex as StdMutex;
807
808        #[derive(Default)]
809        struct Recorder {
810            got: StdMutex<Vec<(String, String)>>,
811        }
812        impl InboundEventHandler for Recorder {
813            fn on_community_message(&self, chat_id: &str, msg: &Message, _new: bool) {
814                self.got.lock().unwrap().push((chat_id.to_string(), msg.content.clone()));
815            }
816        }
817
818        // Offline DB + identity.
819        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
820        crate::db::close_database();
821        crate::db::clear_id_caches();
822        let tmp = tempfile::tempdir().unwrap();
823        let acct = {
824            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
825            let mut s = String::from("npub1");
826            for i in 0..58 {
827                s.push(B[(i * 5 + 1) % 32] as char);
828            }
829            s
830        };
831        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
832        crate::db::set_app_data_dir(tmp.path().to_path_buf());
833        crate::db::set_current_account(acct.clone()).unwrap();
834        crate::db::init_database(&acct).unwrap();
835        let _ = crate::state::take_nostr_client();
836        let me = Keys::generate();
837        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
838        crate::state::set_my_public_key(me.public_key());
839
840        // Create a v2 community (persisted), then ANOTHER member (holds the root)
841        // posts — the incoming case a live sub delivers (an OWN send is echoed at
842        // send time, so its relay copy correctly dedups instead of firing).
843        let relay = MemoryRelay::new();
844        let community = super::super::service::create_community(&relay, "Live", vec!["wss://r".into()], None).await.unwrap();
845        let general = community.channels[0].id;
846        let member = Keys::generate();
847        let group = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
848        let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "live ping", None, &[], vec![], 5_000);
849        let (wrap, _) = super::super::chat::seal_chat_rumor(&rumor, &group, &member, nostr_sdk::prelude::Timestamp::from_secs(5), false).unwrap();
850        let _ = relay.publish(&wrap, &community.relays).await;
851        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
852        let wrap = relay.fetch(&q, &community.relays).await.unwrap().into_iter().find(|w| w.pubkey == group.pk()).unwrap();
853
854        // The realtime dispatch (loading held v2 communities from the DB) routes it.
855        // Dispatch the SAME wrap TWICE — modelling the pool re-delivering it under
856        // the targeted + pool-wide subs (and from multiple relays). The handler
857        // must fire EXACTLY ONCE (no duplicate bot replies) — and only AFTER the
858        // persist outcome (the callbacks-from-persist model).
859        let rec = Arc::new(Recorder::default());
860        let session = SessionGuard::capture();
861        crate::community::v2::realtime::clear().await; // fresh seen-set for the test
862        dispatch_event(&session, wrap.clone(), rec.clone()).await;
863        dispatch_event(&session, wrap, rec.clone()).await;
864
865        let got = rec.got.lock().unwrap();
866        assert_eq!(got.len(), 1, "a re-delivered wrap fires the handler exactly once");
867        assert_eq!(got[0].1, "live ping");
868        assert_eq!(got[0].0, crate::simd::hex::bytes_to_hex_32(&general.0));
869    }
870
871    #[tokio::test]
872    async fn follow_queue_coalesces_a_burst_and_re_enqueues_after_processing() {
873        // Install a test channel in place of the worker's, so we can observe what the
874        // queue delivers without a live client.
875        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
876        *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
877        V2_FOLLOW_PENDING.lock().unwrap().clear();
878
879        let id = CommunityId([0x11; 32]);
880        // A burst for one community collapses to a SINGLE queued follow (coalesced).
881        enqueue_follow(&id);
882        enqueue_follow(&id);
883        enqueue_follow(&id);
884        assert_eq!(rx.recv().await, Some(id), "first trigger queues a follow");
885        assert!(rx.try_recv().is_err(), "the burst coalesced to exactly one");
886
887        // The worker removes it from pending before running; a trigger AFTER that
888        // re-queues (so a change during a follow isn't lost).
889        V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
890        enqueue_follow(&id);
891        assert_eq!(rx.recv().await, Some(id), "a trigger after processing re-queues");
892
893        // A different community is independent (not coalesced against the first).
894        let id2 = CommunityId([0x22; 32]);
895        enqueue_follow(&id2);
896        assert_eq!(rx.recv().await, Some(id2));
897
898        *V2_FOLLOW_TX.lock().unwrap() = None;
899        V2_FOLLOW_PENDING.lock().unwrap().clear();
900    }
901
902    #[tokio::test]
903    async fn a_dissolved_community_honors_no_new_events_and_fires_death_once() {
904        use super::super::service;
905        use crate::community::transport::memory::MemoryRelay;
906        use crate::community::transport::Transport;
907        use crate::types::Message;
908        use std::sync::Mutex as StdMutex;
909
910        #[derive(Default)]
911        struct Recorder {
912            messages: StdMutex<Vec<String>>,
913            deaths: StdMutex<Vec<String>>,
914        }
915        impl InboundEventHandler for Recorder {
916            fn on_community_message(&self, _chat: &str, msg: &Message, _new: bool) {
917                self.messages.lock().unwrap().push(msg.content.clone());
918            }
919            fn on_community_dissolved(&self, community_id: &str) {
920                self.deaths.lock().unwrap().push(community_id.to_string());
921            }
922        }
923
924        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
925        crate::db::close_database();
926        crate::db::clear_id_caches();
927        let tmp = tempfile::tempdir().unwrap();
928        let acct = {
929            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
930            let mut s = String::from("npub1");
931            for i in 0..58 {
932                s.push(B[(i * 3 + 2) % 32] as char);
933            }
934            s
935        };
936        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
937        crate::db::set_app_data_dir(tmp.path().to_path_buf());
938        crate::db::set_current_account(acct.clone()).unwrap();
939        crate::db::init_database(&acct).unwrap();
940        let _ = crate::state::take_nostr_client();
941        let me = Keys::generate();
942        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
943        crate::state::set_my_public_key(me.public_key());
944
945        let relay = MemoryRelay::new();
946        let community = service::create_community(&relay, "Doomed", vec!["wss://r".into()], None).await.unwrap();
947        let general = community.channels[0].id;
948
949        // The owner's tombstone arrives as a MEMBER sees it (local flag still 0 —
950        // build + publish it directly rather than via dissolve_community, which
951        // would seal our own DB first and make it the owner-published case). `me`
952        // is the owner, so the seal verifies.
953        let rumor = super::super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), 8_000);
954        let tombstone = super::super::dissolution::seal_dissolved(&rumor, community.id(), &me, nostr_sdk::prelude::Timestamp::from_secs(8_000)).unwrap();
955        let _ = relay.publish(&tombstone, &community.relays).await;
956        assert!(!crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap(), "not yet locally sealed");
957
958        let rec = Arc::new(Recorder::default());
959        let session = SessionGuard::capture();
960        clear().await;
961        // Fire the SAME tombstone twice AND a fresh re-wrap of its verified seal
962        // (distinct outer id) — death must be announced exactly once.
963        dispatch_event(&session, tombstone.clone(), rec.clone()).await;
964        dispatch_event(&session, tombstone, rec.clone()).await;
965        assert!(crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap());
966
967        // A member posts a fresh message to #general AFTER the tombstone. It must
968        // not be honored (the community is excluded from load_held_v2, so its
969        // plane is NotOurs).
970        let member = Keys::generate();
971        let cgroup = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
972        let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "into the grave", None, &[], vec![], 9_000);
973        let (mw, _) = super::super::chat::seal_chat_rumor(&rumor, &cgroup, &member, nostr_sdk::prelude::Timestamp::from_secs(9), false).unwrap();
974        dispatch_event(&session, mw, rec.clone()).await;
975
976        assert_eq!(rec.deaths.lock().unwrap().len(), 1, "death is announced exactly once");
977        assert!(rec.messages.lock().unwrap().is_empty(), "a post-tombstone message is never honored (CORD-02 §9)");
978    }
979
980    #[test]
981    fn a_private_channel_subscribes_to_its_own_chat_plane() {
982        let mut c = a_community("Priv");
983        c.channels.push(super::super::community::ChannelV2 {
984            id: crate::community::ChannelId([0x33; 32]),
985            name: "mods".into(),
986            private: true,
987            key: Some([0x44; 32]),
988            epoch: Epoch(1),
989            voice: None,
990            meta_custom: None,
991            meta_extra: Default::default(),
992        });
993        let authors = plane_authors(std::slice::from_ref(&c));
994        // A private channel is read under its OWN key/epoch (not the root).
995        let priv_chat = derive::channel_group_key(&[0x44; 32], &c.channels[1].id, Epoch(1)).pk();
996        assert!(authors.contains(&priv_chat), "a private channel subscribes to its own chat plane");
997        // Its next-rekey address IS subscribed (rekey-follow), keyed by the current
998        // root at the channel's next epoch — so a rotation is delivered.
999        let next_rekey = derive::channel_rekey_group_key(&c.community_root, &c.channels[1].id, Epoch(2)).pk();
1000        assert!(authors.contains(&next_rekey), "a private channel's next rekey plane is subscribed");
1001    }
1002}