From 6fac58d4942d7b467c28dd9e9f22910914926986 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 22 Sep 2026 16:03:03 +0700 Subject: [PATCH] update --- crates/community/src/community.rs | 238 ++++++++++++++++++++++++--- crates/community/src/history.rs | 80 +++++++-- crates/community/src/lib.rs | 30 +++- crates/community/src/sync.rs | 74 +++++++-- crates/community_ui/src/lib.rs | 245 +++++++++++++++++++++++----- docs/community-history-sync-plan.md | 196 ++++++++++++++++++---- docs/concord-usage.md | 28 +++- 7 files changed, 774 insertions(+), 117 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 1c7695ae..b905a164 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; +use std::time::{Duration, Instant}; use anyhow::Result; use concord::cord02::{ControlFold, ImageRef}; @@ -10,6 +11,7 @@ use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, Held use concord::{ChannelId, CommunityId, Epoch}; use gpui::{App, AppContext, Context, EventEmitter, Task}; use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use crate::cache; @@ -25,9 +27,12 @@ const CATCH_UP_PAGES: usize = 20; pub const LOAD_OLDER_PAGES: usize = 6; /// Rows one timeline read returns before the caller asks for more. pub const TIMELINE_PAGE: usize = 100; -/// Side events read per row, so a reaction flood cannot displace the rows it -/// decorates. +/// Side events read per row, so a reaction flood cannot displace the rows it decorates. const SIDE_EVENT_FACTOR: usize = 4; +/// The shortest gap between two automatic catch-up rounds for one channel. +pub const MIN_ROUND_INTERVAL: Duration = Duration::from_secs(30); +/// How long a channel may go unsynced before the scheduler repairs it. +const STALE_AFTER: Duration = Duration::from_secs(300); /// Which direction a sync round reads. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -41,6 +46,7 @@ pub enum Intent { pub struct Progress { pub fetched: usize, pub opened: usize, + pub unreadable: usize, pub exhausted: bool, pub failed: bool, pub errors: usize, @@ -102,6 +108,10 @@ pub enum CommunityEvent { Open(CommunityId), Close(CommunityId), Channel(CommunityId, ChannelId), + /// History exists here that no held key can open. + Unreadable(CommunityId), + /// The last round could not reach the community's relays. + Failed(CommunityId), Error(String), } @@ -119,9 +129,17 @@ pub struct Community { icon_task: Option>>, banner_task: Option>>, rounds: HashMap, + /// The last completed round per channel, for the panel's honest states. + progress: HashMap, + /// Wraps held per channel that no key we hold can open. + unreadable: BTreeMap, + /// When each channel's last round started, which paces the automatic ones. + last_round: HashMap, pages: PageRegistry, rekey_task: Option>>, rekey_dirty: bool, + /// Spawned folds, round bookkeeping and publishes, cancelled on drop + tasks: SmallVec<[Task>; 2]>, } impl EventEmitter for Community {} @@ -142,9 +160,13 @@ impl Community { icon_task: None, banner_task: None, rounds: HashMap::new(), + progress: HashMap::new(), + unreadable: BTreeMap::new(), + last_round: HashMap::new(), pages, rekey_task: None, rekey_dirty: false, + tasks: smallvec![], } } @@ -212,6 +234,44 @@ impl Community { self.state.stranded } + /// Wraps held here that no key we hold can open. + pub fn unreadable(&self, channel: &ChannelId) -> usize { + self.unreadable.get(channel).copied().unwrap_or(0) + } + + /// The last round's outcome for `channel`, when one has run. + pub fn progress(&self, channel: &ChannelId) -> Option { + self.progress.get(channel).copied() + } + + /// The epoch a private channel's key is missing for, when we hold none. + pub fn missing_key(&self, channel: &ChannelId) -> Option { + self.state + .channels + .iter() + .find(|held| held.id == *channel && held.private && held.key.is_none()) + .map(|held| held.epoch) + } + + /// The epoch a channel rotation removed us at, when it removed us. + pub fn channel_removed_at(&self, channel: &ChannelId) -> Option { + self.state.channel_cuts.get(channel).map(|cut| Epoch(*cut)) + } + + /// Whether an automatic catch-up for `channel` is worth asking for yet. + pub fn due(&self, channel: &ChannelId) -> bool { + self.last_round + .get(channel) + .is_none_or(|at| at.elapsed() >= MIN_ROUND_INTERVAL) + } + + /// Whether `channel` has been synced before and has since gone stale. + fn stale(&self, channel: &ChannelId) -> bool { + self.last_round + .get(channel) + .is_some_and(|at| at.elapsed() >= STALE_AFTER) + } + pub fn channels(&self) -> &[ChannelKeyRef] { &self.state.channels } @@ -222,12 +282,20 @@ impl Community { /// A public channel derives its write plane from the community root. fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> { + if self.state.removed_at.is_some() || self.state.stranded { + return None; + } + let held = self .state .channels .iter() .find(|held| held.id == *channel)?; + if self.state.channel_cut(channel, held.epoch) { + return None; + } + held.current() .or_else(|| (!held.private).then_some((held.epoch, self.state.community_root))) } @@ -296,18 +364,23 @@ impl Community { round.queued = None; } + self.last_round.insert(channel, Instant::now()); + let round = cx.background_spawn(async move { sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await }); - cx.spawn(async move |this, cx| { + let finisher = cx.spawn(async move |this, cx| { let result = round.await; if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) { log::warn!("community: a channel round outlived its community: {error}"); } - }) - .detach(); + + Ok(()) + }); + + self.tasks.push(finisher); } fn finish_round( @@ -342,20 +415,56 @@ impl Community { self.start_round(channel, intent, cx); } - if let Ok(progress) = &outcome - && progress.failed - && progress.errors > 0 - && progress.fetched == 0 - { - cx.emit(CommunityEvent::Error(format!( - "could not reach {} of the community's relays", - progress.errors - ))); + if let Ok(progress) = &outcome { + self.progress.insert(channel, *progress); + self.record_unreadable(channel, progress.unreadable); + + if progress.failed && progress.errors > 0 { + cx.emit(CommunityEvent::Failed(self.state.id)); + } + + if progress.unreadable > 0 { + cx.emit(CommunityEvent::Unreadable(self.state.id)); + } } cx.emit(CommunityEvent::Updated(self.state.id)); } + /// Remember the wraps no held key could open. + fn record_unreadable(&mut self, channel: ChannelId, count: usize) { + if count == 0 { + return; + } + + let seen = self.unreadable.entry(channel).or_default(); + *seen = (*seen).max(count); + } + + pub(crate) fn tick(&mut self, cx: &mut Context) { + let Some(channel) = self.active_channel() else { + return; + }; + + if !self.stale(&channel) { + return; + } + + self.refresh(cx); + + let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); + + let task = cx.spawn(async move |_this, _cx| { + if let Err(error) = catch_up.await { + log::warn!("community: the scheduler's catch-up failed: {error}"); + } + + Ok(()) + }); + + self.tasks.push(task); + } + /// Fold a round's cursor findings in, monotonically, and persist them. fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context) { let held = self @@ -375,16 +484,19 @@ impl Community { } /// Write the local state document out. - fn persist(&self, cx: &Context) { + fn persist(&mut self, cx: &Context) { let client = NostrRegistry::global(cx).read(cx).client(); let state = self.state.clone(); - cx.background_spawn(async move { + let task = cx.background_spawn(async move { if let Err(error) = cache::save_state(&client, &state).await { log::warn!("community: failed to persist the local state: {error}"); } - }) - .detach(); + + Ok(()) + }); + + self.tasks.push(task); } /// The channel's timeline, folded from the local cache, oldest first. @@ -647,12 +759,15 @@ impl Community { let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); - cx.spawn(async move |_this, _cx| { + let task = cx.spawn(async move |_this, _cx| { if let Err(error) = catch_up.await { log::warn!("community: the catch-up after a rekey failed: {error}"); } - }) - .detach(); + + Ok(()) + }); + + self.tasks.push(task); } /// Rebuilds the community from the wraps in the local database. @@ -686,6 +801,17 @@ impl Community { self.control = snapshot.control; self.members = snapshot.members; self.load_images(cx); + + let reported = !snapshot.unreadable.is_empty(); + + for (channel, count) in snapshot.unreadable { + self.record_unreadable(channel, count); + } + + if reported { + cx.emit(CommunityEvent::Unreadable(self.state.id)); + } + cx.emit(CommunityEvent::Updated(self.state.id)); cx.notify(); } @@ -886,7 +1012,77 @@ async fn sync_round( fn absorb(progress: &mut Progress, page: &WrapPage) { progress.fetched += page.raw; progress.opened += page.opened.len(); + progress.unreadable += page.unreadable; progress.exhausted |= page.exhausted; progress.failed |= page.failed; progress.errors += page.errors; } + +#[cfg(test)] +mod tests { + use super::*; + + /// One public channel under a held root, and nothing else. + fn state(channel: ChannelId) -> CommunityState { + CommunityState { + id: CommunityId::from_bytes([0x42; 32]), + name: None, + owner: Keys::generate().public_key(), + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::new(), + channels: vec![ChannelKeyRef { + id: channel, + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + priors: Vec::new(), + }], + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + cursors: BTreeMap::new(), + held_roots: Vec::new(), + channel_cuts: BTreeMap::new(), + removed_at: None, + stranded: false, + dissolved: false, + added_at_ms: 0, + } + } + + fn community(state: CommunityState) -> Community { + Community::new(state, PageRegistry::default()) + } + + /// A rotation that excluded us leaves no key to write under: a wrap sealed + /// with the retired root would reach nobody who rotated with it. + #[test] + fn a_removed_member_holds_no_write_key() { + let channel = ChannelId::from_bytes([0x9c; 32]); + + // A public channel writes from the community root while we hold it. + assert_eq!( + community(state(channel)).channel_secret(&channel), + Some((Epoch(0), [0x02; 32])) + ); + + // A base removal closes the community... + let mut removed = state(channel); + removed.removed_at = Some(Epoch(1)); + assert_eq!(community(removed).channel_secret(&channel), None); + + // ...a strand closes it too, because the rotation moved past our epoch... + let mut stranded = state(channel); + stranded.stranded = true; + assert_eq!(community(stranded).channel_secret(&channel), None); + + // ...and a channel cut closes the one channel. + let mut cut = state(channel); + cut.channel_cuts.insert(channel, 0); + assert_eq!(community(cut).channel_secret(&channel), None); + } +} diff --git a/crates/community/src/history.rs b/crates/community/src/history.rs index 1de9f023..d8cb6ed1 100644 --- a/crates/community/src/history.rs +++ b/crates/community/src/history.rs @@ -4,8 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; -use anyhow::Result; -use concord::cord01::KIND_WRAP_EPHEMERAL; +use anyhow::{Result, bail}; +use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; use concord::cord03::{self, ChatRumor}; use concord::derive::channel_group_key; use concord::state::{ChannelCursor, HeldKey}; @@ -137,6 +137,8 @@ impl PageRegistry { pub struct WrapPage { pub opened: Vec, pub raw: usize, + /// Wraps that reached us under a held plane but that no held key could open. + pub unreadable: usize, pub newest_ms: Option, pub oldest_ms: Option, pub exhausted: bool, @@ -210,15 +212,8 @@ pub async fn page( continue; }; - // A retired key reads only what was sealed before its rotation published. - if held - .retired_at - .is_some_and(|retired| wrap.created_at.as_secs() > retired) - { - continue; - } - - let Ok((stream, rumor)) = cord03::open(&wrap, group, channel, held.epoch) else { + let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else { + walk.unreadable += 1; continue; }; @@ -231,6 +226,23 @@ pub async fn page( Ok(walk.finish(opened)) } +/// Opens one wrap under a held key. +fn read_under( + wrap: &Event, + held: &HeldKey, + group: &GroupKey, + channel: &ChannelId, +) -> Result<(OpenedStream, ChatRumor)> { + if held + .retired_at + .is_some_and(|retired| wrap.created_at.as_secs() > retired) + { + bail!("sealed after the key that reads it was retired"); + } + + Ok(cord03::open(wrap, group, channel, held.epoch)?) +} + /// The one filter a page is asked for. fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter { let mut filter = Filter::new() @@ -369,6 +381,8 @@ struct Walk { oldest_ms: Option, raw: usize, errors: usize, + /// Wraps the caller could not read under any held key. + unreadable: usize, /// A short page ended the walk. bottom: bool, } @@ -395,6 +409,7 @@ impl Walk { oldest_ms: None, raw: 0, errors: 0, + unreadable: 0, bottom: false, } } @@ -458,6 +473,7 @@ impl Walk { WrapPage { opened, raw: self.raw, + unreadable: self.unreadable, newest_ms: self.newest_ms, oldest_ms: self.oldest_ms, exhausted: swept && self.raw > 0, @@ -586,6 +602,48 @@ mod tests { assert_eq!(page.raw, 3); } + /// A wrap that reaches us and still will not open is history we cannot read, + /// not history that does not exist. + #[test] + fn a_wrap_no_held_key_can_open_reads_as_unreadable() { + let channel = ChannelId::from_bytes([0x9cu8; 32]); + let other = ChannelId::from_bytes([0x9du8; 32]); + let author = Keys::generate(); + let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); + let held = HeldKey { + epoch: Epoch(0), + key: SECRET, + retired_at: Some(1_000), + }; + + let wrap_at = |channel: &ChannelId, at_ms: u64| { + let rumor = build_message( + author.public_key(), + channel, + Epoch(0), + "sealed", + None, + at_ms, + None, + ); + smol::block_on(seal_rumor(&rumor, &group, &author, false)) + .expect("seals") + .0 + }; + + // Sealed before the rotation superseded this key, so it still reads. + let before = wrap_at(&channel, 999_000); + assert!(read_under(&before, &held, &group, &channel).is_ok()); + + // Sealed after the cutoff the rotation set on that key. + let after = wrap_at(&channel, 1_001_000); + assert!(read_under(&after, &held, &group, &channel).is_err()); + + // Sealed to this plane but bound to another channel. + let misbound = wrap_at(&other, 999_000); + assert!(read_under(&misbound, &held, &group, &channel).is_err()); + } + #[test] fn an_empty_answer_never_seals_the_channel() { let database: BTreeSet = BTreeSet::new(); diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 1f7218cb..a1bf72ef 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -5,7 +5,7 @@ use anyhow::Result; pub use concord::cord02::CommunityMetadata; pub use concord::cord03::{ChatMessage, ReplyRef}; use concord::state::CommunityState; -pub use concord::{ChannelId, CommunityId}; +pub use concord::{ChannelId, CommunityId, Epoch}; use futures::future::{Either, select}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; use nostr_sdk::prelude::*; @@ -152,6 +152,8 @@ pub struct CommunityRegistry { notification_listener: Option>>, /// Signal consumer task (cancelled on signer change) signal_consumer: Option>>, + /// The round scheduler (cancelled on signer change) + scheduler: Option>>, _subscriptions: SmallVec<[Subscription; 2]>, } @@ -203,6 +205,7 @@ impl CommunityRegistry { tasks: smallvec![], notification_listener: None, signal_consumer: None, + scheduler: None, _subscriptions: subscriptions, } } @@ -272,6 +275,7 @@ impl CommunityRegistry { pub fn reset(&mut self, cx: &mut Context) { self.notification_listener = None; self.signal_consumer = None; + self.scheduler = None; self.tasks.clear(); self.observers.clear(); self.pages.clear(); @@ -424,6 +428,13 @@ impl CommunityRegistry { community.update(cx, |community, cx| community.rekey(cx)); } + /// One scheduler pass: every community repairs itself if it has gone stale. + fn tick(&mut self, cx: &mut Context) { + for community in self.communities.clone() { + community.update(cx, |community, cx| community.tick(cx)); + } + } + /// Re-subscribe every community whose held planes moved. fn sync_subscriptions(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -491,6 +502,7 @@ impl CommunityRegistry { fn handle_notifications(&mut self, cx: &mut Context) { self.notification_listener = None; self.signal_consumer = None; + self.scheduler = None; let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -558,6 +570,22 @@ impl CommunityRegistry { } Ok(()) })); + + self.scheduler = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(MIN_ROUND_INTERVAL).await; + + if let Some(registry) = this.upgrade() { + registry.update(cx, |this, cx| { + this.tick(cx); + }); + } else { + break; + } + } + + Ok(()) + })); } } diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 454f15c1..d7b51d3e 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -172,6 +172,8 @@ pub struct Snapshot { pub state: CommunityState, pub control: ControlFold, pub members: BTreeSet, + /// Wraps the store holds per channel that no held key can open. + pub unreadable: BTreeMap, } pub async fn create( @@ -529,6 +531,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result = BTreeMap::new(); let mut guestbook_rumors = Vec::new(); + let mut unreadable: BTreeMap = BTreeMap::new(); let mut cached: BTreeMap> = BTreeMap::new(); for plane in &planes { @@ -544,19 +547,23 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result { + // A retired root reads only what was sealed before its rotation. + if !plane.accepts(wrap) { + continue; + } + if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true) { editions.push(edition); } } PlaneKind::Guestbook => { + if !plane.accepts(wrap) { + continue; + } + if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) { observe(&mut observed, rumor.author, rumor.at_ms); guestbook_rumors.push(rumor); @@ -568,11 +575,18 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result { + cache::cache_rumor(client, &channel, &opened).await?; + observe(&mut observed, rumor.author, rumor.at_ms); + } + None => *unreadable.entry(channel).or_default() += 1, } } } @@ -627,6 +641,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result Some((channel, plane)), + _ => None, + }) + .expect("a channel plane"); + + // Sealed to the channel's own address, but not a seal at all, so no + // held key opens it. + let junk = EventBuilder::new(Kind::GiftWrap, "not a seal") + .custom_created_at(Timestamp::from_secs(1_700_000_000)) + .finalize(plane.group.keys()) + .expect("signs"); + client.database().save_event(&junk).await.expect("saves"); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + + assert_eq!(snapshot.unreadable.get(&channel).copied(), Some(1)); + }); + } } diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index d58946a7..051ee021 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -1,9 +1,10 @@ use std::collections::{HashMap, HashSet}; +use std::fmt; use anyhow::Result; use community::{ - ChannelId, ChatMessage, Community, CommunityEvent, Intent, LOAD_OLDER_PAGES, TIMELINE_PAGE, - Timeline, + ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES, + TIMELINE_PAGE, Timeline, }; use gpui::prelude::FluentBuilder; use gpui::{ @@ -20,13 +21,66 @@ use ui::dock::{Panel, PanelEvent}; use ui::input::{InputEvent, Textarea, TextareaState}; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex}; +use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; mod message; /// How near the top row a scroll has to come before the panel splices older history in. const LOAD_OLDER_THRESHOLD: usize = 20; +/// Why a room is not showing the messages it knows about. +/// +/// "No messages yet" is a claim, and a room we cannot read is not an empty one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Notice { + Stranded, + Removed(Epoch), + ChannelRemoved(Epoch), + MissingKey(Epoch), + Unreachable, + Unreadable(usize), +} + +impl fmt::Display for Notice { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Notice::Stranded => formatter.write_str( + "This invite is stale, the community has rotated past the epoch it names", + ), + Notice::Removed(epoch) => write!( + formatter, + "You were removed from this community at epoch {}. Its history stays readable", + epoch.0 + ), + Notice::ChannelRemoved(epoch) => write!( + formatter, + "A rotation removed you from this channel at epoch {}", + epoch.0 + ), + Notice::MissingKey(epoch) => write!( + formatter, + "Messages here can't be read yet — this channel's key for epoch {} is missing", + epoch.0 + ), + Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"), + Notice::Unreadable(1) => { + formatter.write_str("1 message here can't be read yet — no key we hold opens it") + } + Notice::Unreadable(count) => write!( + formatter, + "{count} messages here can't be read yet — no key we hold opens them" + ), + } + } +} + +impl Notice { + /// Whether the room still accepts writes, rather than only being readable. + fn writable(self) -> bool { + matches!(self, Notice::Unreachable | Notice::Unreadable(_)) + } +} + pub fn init( community: Entity, window: &mut Window, @@ -53,6 +107,8 @@ pub struct CommunityPanel { list_state: ListState, /// Message input state input: Entity, + /// Spawned reads and publishes, cancelled when the panel closes + tasks: SmallVec<[Task>; 4]>, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 2]>, } @@ -89,21 +145,24 @@ impl CommunityPanel { subscriptions.push(cx.subscribe_in( &community, window, - |_this, _community, event, window, cx| match event { - // The fold holds the community, and a round caches rows nothing - // has read yet, so re-read once either is released. - CommunityEvent::Updated(_) => { - cx.defer_in(window, |this, window, cx| this.reload(window, cx)); - } - // The sidebar picked another channel while the community was updating. - CommunityEvent::Channel(..) => { - cx.defer_in(window, |this, window, cx| this.load(window, cx)); - } - CommunityEvent::Error(error) => { - window - .push_notification(Notification::error(error.clone()).autohide(false), cx); - } - CommunityEvent::Open(_) | CommunityEvent::Close(_) => {} + |_this, _community, event, window, cx| { + match event { + CommunityEvent::Updated(_) + | CommunityEvent::Unreadable(_) + | CommunityEvent::Failed(_) => { + cx.defer_in(window, |this, window, cx| this.reload(window, cx)); + } + CommunityEvent::Channel(..) => { + cx.defer_in(window, |this, window, cx| this.load(window, cx)); + } + CommunityEvent::Error(error) => { + window.push_notification( + Notification::error(error.clone()).autohide(false), + cx, + ); + } + CommunityEvent::Open(_) | CommunityEvent::Close(_) => {} + }; }, )); @@ -117,6 +176,7 @@ impl CommunityPanel { loading: false, list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), input, + tasks: smallvec![], _subscriptions: subscriptions, }; @@ -195,10 +255,58 @@ impl CommunityPanel { }; self.reload(window, cx); - self.round(channel, Intent::CatchUp, window, cx); + + // Opening a channel twice in a breath asks the relays once. + if self.due(channel, cx) { + self.round(channel, Intent::CatchUp, window, cx); + } } - /// Run a round for `channel`; its completion re-reads the timeline. + /// Whether the community would actually round `channel`, or just serve it. + fn due(&self, channel: ChannelId, cx: &App) -> bool { + self.community + .read_with(cx, |community, _cx| community.due(&channel)) + .unwrap_or(true) + } + + /// Why the room is not showing messages, when it is not simply empty. + fn notice(&self, cx: &App) -> Option { + let channel = self.channel?; + + self.community + .read_with(cx, |community, _cx| { + if community.stranded() { + return Some(Notice::Stranded); + } + + if let Some(epoch) = community.removed_at() { + return Some(Notice::Removed(epoch)); + } + + if let Some(epoch) = community.channel_removed_at(&channel) { + return Some(Notice::ChannelRemoved(epoch)); + } + + if let Some(epoch) = community.missing_key(&channel) { + return Some(Notice::MissingKey(epoch)); + } + + if community + .progress(&channel) + .is_some_and(|progress| progress.failed && progress.errors > 0) + { + return Some(Notice::Unreachable); + } + + let unreadable = community.unreadable(&channel); + + (unreadable > 0).then_some(Notice::Unreadable(unreadable)) + }) + .ok() + .flatten() + } + + /// Run a round for `channel`, its completion re-reads the timeline. fn round( &mut self, channel: ChannelId, @@ -211,8 +319,9 @@ impl CommunityPanel { }; self.loading = true; + cx.notify(); - cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { let result = round.await; this.update_in(cx, |this, window, cx| { @@ -227,8 +336,18 @@ impl CommunityPanel { })?; Ok(()) - }) - .detach(); + }); + + self.tasks.push(task); + } + + /// Run the newest round again after a relay failure. + fn retry(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.channel else { + return; + }; + + self.round(channel, Intent::CatchUp, window, cx); } /// Read the newest page and fold it into what is on screen. @@ -241,7 +360,7 @@ impl CommunityPanel { return; }; - cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { match timeline.await { Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?, Err(error) => { @@ -255,8 +374,9 @@ impl CommunityPanel { } Ok(()) - }) - .detach(); + }); + + self.tasks.push(task); } /// Splice the page of history above the oldest row on screen. @@ -283,7 +403,7 @@ impl CommunityPanel { self.loading = true; - cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { let timeline = match page.await { Ok(timeline) => timeline, Err(error) => { @@ -356,8 +476,9 @@ impl CommunityPanel { } Ok(()) - }) - .detach(); + }); + + self.tasks.push(task); } /// Fold a freshly read window into the rows on screen. @@ -446,6 +567,14 @@ impl CommunityPanel { return; }; + // A rotation that excluded us moved the write plane out of reach, and the + // community refuses to seal a message nobody could read. The notice above + // the list is what says why. + if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) { + window.push_notification(Notification::error(notice.to_string()).autohide(false), cx); + return; + } + let Ok(send) = self.community.read_with(cx, |community, cx| { community.send(&channel, &content, None, cx) }) else { @@ -461,7 +590,7 @@ impl CommunityPanel { input.set_value("", window, cx); }); - cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { match send.await { Ok(_) => { this.update_in(cx, |this, window, cx| this.reload(window, cx))?; @@ -477,8 +606,34 @@ impl CommunityPanel { } Ok(()) - }) - .detach(); + }); + + self.tasks.push(task); + } + + /// The honest reason this room has nothing to show, with a way out of it. + fn render_notice(&self, notice: Notice, cx: &mut Context) -> AnyElement { + h_flex() + .w_full() + .justify_center() + .items_center() + .gap_2() + .px_3() + .py_2() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child(notice.to_string()) + .when(notice == Notice::Unreachable, |this| { + this.child( + Button::new("retry-round") + .label("Retry") + .ghost() + .small() + .loading(self.loading) + .on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))), + ) + }) + .into_any_element() } /// The row at index 0: the affordance that pages older history in. @@ -524,6 +679,8 @@ impl CommunityPanel { } fn render_composer(&self, cx: &mut Context) -> impl IntoElement { + let writable = self.notice(cx).is_none_or(|notice| notice.writable()); + h_flex() .flex_shrink_0() .w_full() @@ -537,6 +694,7 @@ impl CommunityPanel { .tooltip("Send") .ghost() .large() + .disabled(!writable) .on_click(cx.listener(|this, _event, window, cx| { this.send(window, cx); })), @@ -591,17 +749,26 @@ impl Render for CommunityPanel { .min_h_0() .relative() .map(|this| { + let notice = self.notice(cx); + if self.rows.is_empty() { this.child( - h_flex() - .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_placeholder) - .child("No messages yet"), + v_flex().size_full().justify_center().child(match notice { + Some(notice) => self.render_notice(notice, cx), + None => h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child("No messages yet") + .into_any_element(), + }), ) } else { - this.child( + this.when_some(notice, |this, notice| { + this.child(self.render_notice(notice, cx)) + }) + .child( list( self.list_state.clone(), cx.processor(move |this, ix, window, cx| { diff --git a/docs/community-history-sync-plan.md b/docs/community-history-sync-plan.md index 3710eb69..35305d24 100644 --- a/docs/community-history-sync-plan.md +++ b/docs/community-history-sync-plan.md @@ -37,7 +37,7 @@ wait, and it is reported rather than rendered as an empty channel. Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b swapped the transport and put the pump in charge of settling pages, phase 3 added -the rekey watch and held epochs): +the rekey watch and held epochs, phase 4 made an empty or unreadable room say so): ``` CommunityPanel::load community_ui/src/lib.rs:192 @@ -72,6 +72,12 @@ live wire community/src/lib.rs:309 (sync_subscriptions) back from the database, adopts one epoch at a time) + +scheduler community/src/lib.rs (one tick per + -> Community::tick community/src/community.rs MIN_ROUND_INTERVAL; + a channel that has + passed STALE_AFTER + re-folds and re-rounds) ``` ## What is wrong today @@ -85,7 +91,7 @@ live wire community/src/lib.rs:309 (sync_subscriptions) | 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | fixed in phase 2b (`planes` derives a private channel's plane from the held key; it is subscribed, paged and folded like a public one) | `community/src/sync.rs` | | 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | fixed in phase 2b (`live_filter`/`plane_filter` ask for both kinds; the pump routes by subscription id and never inspects the kind) | `community/src/sync.rs`, `community/src/lib.rs` | | 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | fixed in phase 3 (`priors`/`held_roots` + `retired_at`, the rekey watch, and strict one-epoch-at-a-time adoption) | `community/src/rekey.rs`, `concord/src/state.rs` | -| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` | +| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | fixed in phase 4 (`WrapPage`/`Progress`/`Snapshot` carry `unreadable`, the community exposes `progress`/`unreadable`/`missing_key`/`channel_removed_at`/`removed_at`/`stranded`, and the panel renders a reason plus a retry instead of an empty room) | `community/src/community.rs:46-53`, `community_ui/src/lib.rs:235` | | 9 | A new message **replaced the whole timeline and forced `scroll_to_end()`**. | fixed in phase 1 (`FollowMode::Tail`, in-place merge) | `community_ui/src/lib.rs:121`, `:364-411` | | 10 | Backfill fetched through `client.fetch_events(..)` with `ReqTarget::auto`, i.e. every relay in the pool. | fixed in phase 1 (relay-scoped, no `fetch_events` anywhere) | `community/src/community.rs:262` | | 11 | The local cache document is authored by a **process-random key**, so the same rumor cached in two runs is a different event id and the store keeps both copies; `fold` then re-reads all of it on every inbound wrap. | fixed in phase 2a (one fixed cache key) | `community/src/cache.rs` | @@ -515,19 +521,96 @@ Three deviations from the sketch above, all of them smaller than planned: adoptable, so a member who missed several rotations catches up in one database read instead of one pass per poll. The lookahead window is what feeds it. -Still deferred, and named here so it is not mistaken for landed: the panel does -not yet render `Community::removed_at()` / `Community::stranded()` (phase 4), and -a base removal is not enforced at send time — `channel_secret` still hands the -composer the retired root. +Still deferred, and named here so it is not mistaken for landed: a rotation this +client published itself is not adopted locally (no rekey writer exists yet, so the +watch only ever adopts another member's). -### 7. Honest states (phase 4) +### 7. Honest states (phase 4) — **landed** -- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`; - the fold reports what it could not open; `Snapshot` carries those counts. -- `CommunityEvent` gains "history exists that we cannot read" and "the last round - failed", and the panel renders "N messages here can't be read yet — the channel's - key for epoch 3 is missing" or "Couldn't reach the community's relays" with a - retry, instead of "No messages yet" (finding 8). +"No messages yet" is a claim, and finding 8 is that the client made it in three +situations it could not tell apart. The rule is that a room only says it is empty +when it *knows* it is empty; otherwise it says what is actually wrong. + +What is carried out of the read path: + +- `history::WrapPage.unreadable` counts the wraps a page reached under a held + plane that no held key could open — sealed past the cutoff a rotation set on the + key that reads them, or bound to another channel. `Walk`'s count is the page's; + `Progress.unreadable` sums the pages of a round. +- `sync::Snapshot.unreadable` is the same count over the whole store, per channel, + so a wrap is counted whether it arrived on a round or on the live wire. A wrap + already opened on the way in stays readable through its cached row and is never + counted. +- `Community` keeps the last completed round's `Progress` per channel and the + `unreadable` counts, merged monotonically: an unreadable wrap stays unreadable, + so a later quiet round cannot erase the count. + +What the community answers: + +- `progress(channel)`, `unreadable(channel)`, `missing_key(channel)` (a private + channel we know and hold no key for, with the epoch), `channel_removed_at(channel)` + (a channel rotation's cut), plus the phase-3 `removed_at()` and `stranded()`. +- `due(channel)`: whether an automatic catch-up is worth asking for yet. + +What the panel renders, in precedence order, instead of an empty room: + +| State | Rendered | +| ----- | -------- | +| `stranded()` | "This invite is stale — the community has rotated past the epoch it names" | +| `removed_at()` | "You were removed from this community at epoch N. Its history stays readable" | +| `channel_removed_at()` | "A rotation removed you from this channel at epoch N" | +| `missing_key()` | "Messages here can't be read yet — this channel's key for epoch N is missing" | +| `progress.failed && progress.errors > 0` | "Couldn't reach the community's relays" + **Retry** | +| `unreadable(channel) > 0` | "N messages here can't be read yet — no key we hold opens them" | +| otherwise, no rows | "No messages yet" | + +The notice replaces the empty state, and sits as a one-line strip above the rows +when there are rows, so a stale room says it may be stale rather than looking +complete. Only *Retry* is actionable, and it runs the round directly — the +`MIN_ROUND_INTERVAL` gate below only paces automatic rounds. + +One of those states is about writing, not reading. A room a rotation removed us +from, a channel it cut, a key we never held, or a stale invite all mean the same +thing at the wire: `channel_secret` is `None`, so a wrap sealed now would be +sealed under a root nobody who rotated reads. `Community::send` therefore refuses +in those cases (§6's deferral, closed), and the panel disables the send button +while the notice says why. A channel that is merely unreachable or partly +unreadable still writes: reading and publishing are separate paths. + +Two `CommunityEvent`s carry the same news to any other view: `Failed(id)` (the +last round could not reach the relays) and `Unreadable(id)` (history here that no +held key opens). `Failed` replaces the `Error(..)` toast a failed round used to +raise, because the panel now says it in place. + +### 7b. The round scheduler (phase 4) — **landed** + +Two constants in `community/src/community.rs`: + +- `MIN_ROUND_INTERVAL = 30s` — `Community::due(channel)` is false while a round + for that channel ran inside the window. The panel's automatic round on open (and + on a channel switch) is what consults it, so opening a channel twice in a breath + asks the relays once. A round for `Older`, a retry, and the catch-up a rekey + adoption triggers all bypass it — only the automatic open is paced. +- `STALE_AFTER = 300s` — `Community::tick` re-folds, and re-rounds the active + channel, once a channel that has been *synced before* has gone unsynced that + long. A channel with no recorded round is left alone, so a community nobody has + opened costs nothing, and a quiet one asks its relays at most once per five + minutes. + +`CommunityRegistry` owns one task for this, armed beside the pump and the signal +consumer in `handle_notifications` and cleared in `reset`, so a signer change +stops it and re-arms it. + +### 7c. Not done in phase 4 + +- **NIP-77 (`client.sync`) catch-up.** Still skipped deliberately. A negentropy + reconciliation is a second way to ask the same question, and getting its + "unsupported"/partial answers right — never reading one as `exhausted` — is its + own piece of work with its own failure modes. The paged walk is the fallback the + plan already specifies, so nothing here blocks on it. +- **The fold's control and guestbook planes** keep logging an unopenable wrap + rather than counting it: `Snapshot.unreadable` is per channel, and there is no + honest place yet to render "the community's own metadata is unreadable". ### 8. `community_ui`: reach the older rows @@ -542,8 +625,10 @@ Two rules survive, both about GPUI rather than about history: - **Never `set_follow_mode` or force a scroll position inside a scroll-handler callback** — the list holds its state borrowed while it invokes the handler, so touching `ListState` there panics. `load_older` is written to avoid it. -- The panel's `Intent::{CatchUp, Older}` calls do not change: the round they - trigger is now subscription-driven, which is invisible to the list. +- The panel's `Intent::{CatchUp, Older}` calls do not change the list: the round + they trigger is subscription-driven, which is invisible to it. Phase 4 added one + gate in front of the `CatchUp` one — `due()` — and it only paces how often the + relays are asked, not what the list does with an answer. Still deferred: per-channel timeline state (switching back re-reads), and the `MAX_TIMELINE_ROWS` trim (trimming the oldest rows fights `load_older`, which @@ -650,19 +735,54 @@ Also landed as the cheap win §3 promised: the older pass is skipped entirely wh `cargo +nightly fmt -p concord -p community -p community_ui --check`, `cargo check -p workspace --all-targets`. -### Phase 4 — honest states and polish +### Phase 4 — honest states and polish — **landed** -§7 (empty/unreadable/failed in the panel, using the counts that already exist), -round progress in the UI, the `MIN_ROUND_INTERVAL = 30s` / `STALE_AFTER = 5min` -scheduler, the `removed`/`stranded` rendering phase 3 persisted but left -unpainted, and optional NIP-77 catch-up (`client.sync(filter)` where a relay -supports negentropy; "negentropy unsupported" means "fall back to the paged -walk", never "exhausted"). +§7, §7b: the read path counts what it cannot open (`WrapPage`/`Progress`/ +`Snapshot` `unreadable`), the community exposes the honest read surface +(`progress`, `unreadable`, `missing_key`, `channel_removed_at`, `due`, beside the +phase-3 `removed_at`/`stranded`), the panel renders a reason and a retry instead +of "No messages yet", the round scheduler paces automatic rounds +(`MIN_ROUND_INTERVAL`) and repairs a stale one (`STALE_AFTER`), and §6's +send-time gap is closed: a removed, cut, keyless or stranded room holds no write +key. + +Gate, all green: `cargo test -p concord -p community` (50 + 32), +`cargo clippy -p concord -p community -p community_ui --all-targets`, +`cargo +nightly fmt -p concord -p community -p community_ui --check`, +`cargo check -p workspace --all-targets`. + +Five recorded deviations: + +- **A one-line strip above the rows, not only an empty state.** §7's finding is + about the empty room, but the stale case — rows on screen, relays unreachable or + history unreadable — is the same lie in a quieter form, and it is the one the + original report is actually about ("not full of messages and latest data"). A + failed round's strip is transient: the next round that succeeds replaces + `progress` and clears it. An `unreadable` count is monotone by design, because a + wrap no held key opens stays unopened. +- **`Failed` replaces the `Error(..)` toast** on a failed round rather than + accompanying it, so the panel is the single place that reports it. +- **`due()` gates the panel's automatic round, not `sync_channel`.** The plan + named the interval but not the seam; keeping the gate on the caller means every + explicit request (retry, load-older, a rekey's catch-up) stays exact. +- **§6's send-time deferral is closed in the same phase.** The plan framed it as a + wire-level gap to decide later, but it is the same lie in the other direction: a + member a rotation excluded could type, press enter, watch the message disappear + into a plane nobody reads, and be told nothing. `channel_secret` refusing is what + makes the notice actionable. +- **Spawned work is tracked, not detached.** `Community::tasks` and + `CommunityPanel::tasks` hold every fold, round bookkeeping step and publish, so + closing a panel or signing out cancels them instead of letting them finish + against a client nobody holds. Each push drops the tasks that already finished. + +Phase 4 leaves the client honest about what it can see and what it can write. +What it does not do is make it see more: the rekey writer, and with it adopting a +rotation this client published itself, remains open (see §6). Each phase leaves the client consistent on its own. Phase 2a was invisible; phase 2b is what makes "open a community" a subscription and a database read; phase 3 -is what keeps a rekey from stranding history. Phase 4 (§7) is next: it is what -makes an empty or unreadable room tell the truth. +is what keeps a rekey from stranding history; phase 4 is what makes an empty or +unreadable room tell the truth. ## Phase 1 status (landed) @@ -729,6 +849,20 @@ outstanding are the ones that need a GPUI harness or two live accounts. receives nothing. - The read path: the side-event budget folds an edit/delete/reaction older than the row window onto its message. +- What cannot be read — **landed in phase 4**: a wrap sealed after the rotation + that retired the key reading it, and a wrap sealed to this plane but bound to + another channel, both read as unreadable rather than dropped + (`community/src/history.rs`); a fold counts a wrap addressed to a held channel + plane that will not open (`community/src/sync.rs`). +- The honest states — **landed in phase 4** structurally: `Snapshot.unreadable` + and `Progress.unreadable` are what the panel's notice and the `Unreadable` + event read, and the `Failed` event is what a failed round emits. The panel's + precedence (stranded → removed → missing key → unreachable → unreadable → + empty) itself needs a `TestAppContext`, which the repo still does not have. +- The scheduler — **landed in phase 4** structurally: `due()` is false inside + `MIN_ROUND_INTERVAL` after a round, `stale()` requires a recorded round older + than `STALE_AFTER`, and `tick` acts only on the active channel. Driving real + time needs the same harness. - The fold: a new live wrap costs one decrypt, and a fold over a community with 5,000 cached rows does not re-open them. (The skip is in place and structurally tested by `wrapper_index`; counting decrypts needs a harness.) @@ -736,13 +870,15 @@ outstanding are the ones that need a GPUI harness or two live accounts. regression that duplicates a community's history per app run). **Landed in 2a.** - GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a live message does not scroll a reader who is scrolled up; `has_more == false` - disables the load-older row. (No GPUI test harness exists in the repo yet.) + disables the load-older row; the panel's notice precedence; `due()`/`stale()` + under a driven clock. (No GPUI test harness exists in the repo yet.) - Manual runs: two accounts, a channel with more than 200 messages, one account - offline long enough to miss a full page, one private channel, one rekey. The - acceptance bar is the reference behaviour: open a channel cold and see history - arrive in pages without touching the scrollbar, reopen it and see one REQ with a - `since` instead of a replay, and see the other account's message appear without - a reload. + offline long enough to miss a full page, one private channel, one rekey, and one + run with a relay stopped so the notice and its retry are visible. The acceptance + bar is the reference behaviour: open a channel cold and see history arrive in + pages without touching the scrollbar, reopen it and see one REQ with a `since` + instead of a replay, see the other account's message appear without a reload, + and see a room we cannot read say so instead of "No messages yet". ## Out of scope diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 39fde0e0..982e2e1a 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -274,7 +274,10 @@ than writing off a relay that only wanted to authenticate. reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`, `exhausted` is earned only by a short page *after* history was seen, and an all-empty answer sets `failed` so a later round re-asks instead of sealing the -channel at "no more history". Page down with `Window::older_than(seen.oldest_ms)`, +channel at "no more history". `unreadable` counts the wraps the page reached that +no held key could open — sealed past the cutoff their key's rotation set, or bound +to another channel — because those are history the reader is missing, not history +that is not there. Page down with `Window::older_than(seen.oldest_ms)`, open a channel with `Window::opening(cursor)` (wide cold, `newest_ms - 60s` warm), and read the region between two cursors with `Window::between(..)`. `query_rumors` is the read path when the group keys are gone; pass `kinds` to budget rows apart @@ -284,6 +287,18 @@ author and message times without re-opening a wrap it already cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every round — the timer is cooperative, so the local store is the artifact that has to forget. +`sync::fold` counts the same thing over the whole store, per channel, in +`Snapshot.unreadable`. `sync_round` sums a round's pages into +`Progress.unreadable`, and `Community` keeps both per channel: `progress(channel)` +is the last completed round, `unreadable(channel)` is the count, and +`missing_key(channel)`, `channel_removed_at(channel)`, `removed_at()` and +`stranded()` are the rest of the honest read surface a panel needs to tell an +empty room from one it cannot read. `Community::due(channel)` is the other half — +whether a round is worth asking for yet (a round for `Older` and an explicit retry +never ask it), and `Community::tick`, driven by `CommunityRegistry` once per +`community::MIN_ROUND_INTERVAL`, re-folds and re-rounds the active channel of a +community whose last round is older than `STALE_AFTER`. + `ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it as an inline row only when its author passes `control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`. @@ -717,7 +732,10 @@ client.subscribe(filter).with_id(sub_id).await?; retired root's Control signer and the publish time that retires it), and each `ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read side derives planes from all of them, and a retired key's `retired_at` is a hard - read cutoff at both `history::page` and `sync::fold`. The residual gaps: the - panel does not yet render the `removed`/`stranded` state `Community::removed_at` - and `Community::stranded` carry (phase 4), and a base removal is not yet enforced - at send time — the composer still has the old root to write under. + read cutoff at both `history::page` and `sync::fold`. The state + `Community::removed_at`/`Community::stranded`/`channel_removed_at` carries is + rendered as a notice **and** enforced at write time: `Community::send` refuses + when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we + never held), because a wrap sealed under a superseded root would reach nobody who + rotated. The residual gap: this client does not adopt a rotation it published + itself, because there is no rekey writer yet.