Files
coop/docs/community-history-sync-plan.md
T
2026-09-22 14:50:15 +07:00

42 KiB
Raw Blame History

Community history and live sync

A community panel currently shows a short, stale slice of a channel: often only the newest handful of wraps a relay happened to replay, no older history, no messages for private channels, and nothing at all after a rekey. The reference client (soapbox-pub/armada, src/concord/lib/channelSync.ts and friends) pages history from relays into a local store, keeps a cursor per channel, and keeps the timeline live. This document is the diagnosis and the plan to get there.

Revision 2. Three corrections to the first revision, all of them structural:

  1. concord stays a thin protocol layer. Relay and database operations do not belong in it. Revision 1 put the whole paged fetch — fetch_page, Window, WrapPage, Walk, the per-page subscriptions — into concord/src/store.rs, next to a local cache layer that writes to the client database. That is now framed as the mistake it is: concord keeps wire formats, crypto, and the plain community document; community keeps every Client.
  2. One notification pump, subscriptions that stay. community already has a global relay notification handler (CommunityRegistry::handle_notifications). The history path must go through it: subscribe to a filter, read the page back from the local database, keep the live subscription alive for new data, and let the pump — not a hand-rolled per-relay wait loop inside the protocol crate — be the only consumer of relay notifications.
  3. Cold asks for everything, warm asks for what is new. Opening a community with nothing cached subscribes wide (since = None) and pages down; opening it again asks only for the region since the cursor. Exhaustion still has to be earned (§3).

And one correction to a claim in revision 1: an auth-required CLOSE is not a failure. The SDK marks that subscription for resubscription and re-issues the REQ under the same id once the AUTH handshake completes — for auto-closing subscriptions too, which is what a page REQ is (nostr-sdk/src/relay/inner.rs handle_relay_messageMarkAsClosed, and the auto-closing handler's RelayNotification::Authenticated arm). Only AuthenticationFailed ends a relay's 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):

CommunityPanel::load              community_ui/src/lib.rs:192
  -> Community::sync_channel      community/src/community.rs:220
       -> sync_round              community/src/community.rs      (purge_expired,
                                                                  Window::opening)
            -> history::page      community/src/history.rs      (one REQ per page
                 -> ask_page      community/src/history.rs      over the community's
                                                                  relays, registered
                                                                  in the page registry)
                 -> PageRegistry  community/src/history.rs      (EOSE / CLOSED arrive
                                                                  through the pump)
                 -> client.database().query(filter)          (the page)
            -> cache::cache_rumor community/src/cache.rs
  -> Community::timeline          community/src/community.rs:370
       -> cache::query_rumors     community/src/cache.rs
       -> cord03::fold            concord/src/cords/cord03.rs

live wire                         community/src/lib.rs:309  (sync_subscriptions)
  -> sync::live_filter            community/src/sync.rs      (both wrap kinds,
                                                                private channels,
                                                                Window::opening,
                                                                limit LIVE_REPLAY)
  -> pump                          community/src/lib.rs:433  (routes by subscription
                                                                id, batches within
                                                                PUMP_WINDOW, settles
                                                                pages by EOSE/CLOSED)

What is wrong today

# Finding Status Evidence
1 History was fetched at most once per channel, only when the cache was completely empty, so the relay round was almost always skipped. fixed in phase 1 (sync_channel runs per open) community/src/community.rs:218
2 That one round was one shallow page with no continuation. fixed in phase 1 (paged walk + cursors) community/src/history.rs
3 The timeline was a fixed 200-row window with no way to ask for older. fixed in phase 1 (timeline + has_more + load-older) community_ui/src/lib.rs:263
4 Cursors existed but nothing used them; no "has more" signal. fixed in phase 1 community/src/community.rs:337
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. open (phase 3) community/src/sync.rs:331-368, community/src/community.rs:199-216
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
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
12 cache::purge_expired is never called, so expired rows only drop at fold time, never from disk. fixed in phase 2b (sync_round sweeps the channel before it starts) community/src/community.rs
13 Every relay and database operation lived in concord: fetch_page installed its own subscriptions and read the client database, cache_rumor/query_rumors/save_state/load_states wrote and read it. Consequences: the pump could not see a page's REQ (so the panel's live view and the history path were two unrelated worlds), each page hand-rolled a per-relay notification loop, and the protocol crate could not be built or tested without a Client. fixed in phase 2a (split into concord/src/state.rs, community/src/cache.rs and community/src/history.rs; the page REQ still waits on its own loop — §2 moves it into the pump) concord/src/state.rs, community/src/{cache,history}.rs
14 The fold is O(history): sync::fold re-reads every wrap in the community's planes and NIP-44-opens each one on every inbound wrap, purely to cache channel rumors and observe their authors for the member list. The live REQ replays the plane on every start, so this happens on every app run and every burst of messages. fixed in phase 2b (channel wraps are opened once, on the way in; fold reads already-cached authors and times from cache::wrapper_index instead of opening them again) community/src/sync.rs, community/src/cache.rs

Findings 1-4 and 9-10 are the user-visible symptom and were phase 1; 13 is where that work landed in the wrong crate; 5-7 are why some channels look empty forever; 8 is why a failure looks like an empty room; 11 and 14 are why the store and the fold get slower the fuller a community is.

What the reference client does

Observed in Armada (src/concord/lib/channelSync.ts, src/concord/hooks/useChannel.ts, src/wire/spec.ts, src/concord/hooks/useRekey.ts):

Mechanism Reference detail
Local-first read the query resolves on an IndexedDB read (WINDOW_SIZE = 100 rows) and network catch-up runs behind it, so the panel never waits on relays
Three-pass round newest pagebridge (since: cursor.newest, until: newest.oldest - 1, healing an offline burst larger than one page) → older (until: cursor.oldest, resumed on later rounds)
Page size BACKFILL_PAGE = 50 wraps per relay per page, BACKFILL_MAX_PAGES = 20 per round, LOAD_OLDER_MAX_PAGES = 6 per scroll-up
Cursors { newest, oldest, exhausted } per channel, persisted locally, merged monotonically (newest forward only, oldest back only, exhausted sticky), advanced only when the region is verifiably complete
Failure semantics a failed relay blocks exhaustion and cursor advancement; an all-empty round is a failure, never exhaustion (otherwise a notification-only room is sealed as "exhausted" and never pulls its history)
Page-by-page decode each page is decrypted and committed as it lands; a deep round paints as it goes
Scroll-up paging local store first (pure re-read), relays only when the store is exhausted
Side events messages and their decorations (edits/deletes/reactions) get separate budgets (×4) so reaction floods cannot displace rows and edits outside the row window still fold
Live wire one REQ per relay with the channel's current stream addresses; every held epoch stays in the decode set; retired epochs are asked once more, then frozen out of the REQ
Rekeys a watch on baseRekeyGroupKey(root, id, rootEpoch + 1) plus CHANNEL_REKEY_LOOKAHEAD = 8 epochs of channel rekey pseudonyms; adoption clears exhausted and re-runs the channel's sync round
Polling a scheduler re-runs a channel round when it is older than 5 minutes, at most once per 30 s

The flow we are converging on: subscribe, then read the database

The Signed app (crates/signed_state/src) is the model, and it maps onto what community already has:

Signed coop
Backend owns the Client and runs one notification pump that batches relay events into BackendEvent::NostrUpdate(Vec<Update>) within PUMP_DEBOUNCE = 200ms state::NostrRegistry owns the Client; CommunityRegistry runs the pump
Backend::subscribe_bootstrap(filters) / sync_bootstrap(filter) install the REQ; the pump delivers what relays send into the database CommunityRegistry::sync_subscriptions installs the live REQ; a round installs a page REQ
Stores (RepoListStore, ProfileStore) subscribe to backend events and re-query only client.database(), never the network Community::refreshcommunity::cache reads
RefreshGate (running/dirty, one coalesced follow-up) Community::{refresh_task, dirty} and Round{running, queued, waiters}
A store never awaits a relay: it reacts to a batched update and reads the database a round awaits its own page's EOSE (through the pump) and then reads the database

Two consequences for this codebase, stated as rules:

  • A relay is only ever asked by a subscription. No fetch_events, no ReqTarget::auto, no per-relay notifications() loop outside the pump.
  • Every read is a database read. Cursors, folds, timelines, member lists and pages are computed from what the database holds after a subscription put it there.

The fix

1. concord keeps the protocol, community keeps the relays and the database

concord gets one rule: no Client, no database, no subscription. Its remaining job is wire formats, crypto and the community document. Everything that talks to a relay or the local store moves one crate up:

Today Target
concord/src/store.rs: CommunityState, ChannelKeyRef, ChannelCursor (+ merge), from_genesis, from_join_material, apply_fold, floors, identifier, list_entry concord/src/state.rs — the document and its pure folds, no I/O
cache_rumor, purge_expired, query_rumors, save_state, load_state, load_states, state_identifier, LOCAL_KEYS, the c/t/k/e tags, STATE_PREFIX community/src/cache.rs — the local cache and the local documents
Window, WrapPage, fetch_page (renamed history::page), wrap_filter, ingest_page, auth_required, history_subscription, within, Walk, Walker community/src/history.rs — the page REQ and the walk
planes, subscription_filter, subscription_id, community_of, subscribe_list, load, fold stay community/src/sync.rs, extended
Signal, handle_notifications, sync_subscriptions, track, reset stay community/src/lib.rs, extended into the pump (§2)
The Walk/serve_page tests move to community/src/history.rs; the ChannelCursor::merge and from_join_material tests stay with the document; the load_states test moves to community (it needs a database)

Notes:

  • The document module is concord::state; items are imported by full path (use concord::state::{ChannelCursor, CommunityState};) so the separate state crate keeps state::… for itself.
  • concord's dependency list shrinks: futures and smol are used only by the relay code that moves out (smol stays, as a dev-dependency, for the cord tests).
  • ChannelCursor stays a plain data type inside CommunityState (it is persisted, and its merge is pure). What moves out is when to advance it — that is community's judgement, not the protocol's.
  • Two deviations from the table, both to keep the layers honest. STATE_PREFIX, state_identifier and CommunityState::identifier stay in concord::state: they name the document's own local key, and the document cannot reach up into community to borrow them. And community's cache and history are pub mod, so the surface that used to be concord::store is still reachable (a private module turns purge_expired and load_state into dead code).
  • docs/concord-usage.md documented store::fetch_page; it was updated in the same change.

2. One notification pump, and subscriptions that stay

Subscription ids and their routes

Subscription Id Owner Lifetime
Community List concord/list (existing) registry signer lifetime
Live planes <community hex> (existing sync::subscription_id) community until the signer or the plane set changes; kept alive
History page concord-history-<n> (opaque, unique) the round one page; auto-closes on EOSE
Rekey watch (phase 3) concord-rekey/<community hex>/<n> community kept alive while the community is tracked

route_of(&SubscriptionId) -> Option<Route> parses the two standing ids into Route::{List, Community(CommunityId)}, so the pump routes an event by subscription id and never has to guess from the event itself (which is what keeps another crate's subscription out of a community's fold).

A page id cannot be parsed this way, and that is deliberate: a NIP-01 subscription id is capped at 64 characters, which a community hex alone fills, so an id cannot carry both a community and a channel. A page's id is therefore an opaque concord-history-<n> and the pump resolves it through the page registry instead — PageRegistry, a HashMap<SubscriptionId, flume::Sender<PageReport>> shared between the registry and the rounds. PageReport { relay, outcome } carries facts; the walk decides what they mean. A report for an id nobody is waiting on is dropped at debug, which is the ordinary case when a round is cancelled. Phase 3 can add a Rekey route the same way, by id.

The pump

CommunityRegistry::handle_notifications becomes the only consumer of client.notifications(), and it handles the message variants it drops today (route and the window loop live in community/src/lib.rs):

fn route(notification, pages, batch) -> Flow {
    match notification {
        ClientNotification::Event { subscription_id, .. } => match route_of(&subscription_id) {
            Some(Route::List) => batch.list = true,
            // Only the database is told; the fold reads it when the window closes.
            Some(Route::Community(id)) => { batch.communities.insert(id); }
            // A page's event needs no routing at all: the subscription already put
            // it in the database, and the round reads it back from there.
            None => {}
        },
        ClientNotification::Message { relay_url, message } => match *message {
            RelayMessage::EndOfStoredEvents(id) => pages.deliver(&id, relay_url, Settled::Replayed),
            RelayMessage::Closed { subscription_id, message }
                if !auth_required(&message) =>
            {
                pages.deliver(&subscription_id, relay_url, Settled::Refused(message.into_owned()));
            }
            // auth-required: the SDK re-issues this REQ under the same id after
            // AUTH, so the page keeps waiting for the resubscribed answer.
            _ => {}
        },
        ClientNotification::Shutdown => return Flow::Stop,
    }
    Flow::Continue
}
  • Kind 1059 and 21059 both route (finding 6); the previous revision's "drops every non-1059" check goes away entirely.
  • The window is a fixed PUMP_WINDOW (200 ms, the reference app's value) from the first notification of a burst, not a debounce that a steady stream can keep extending. What it produces is one Signal::Event(id) per community that saw an event plus one Signal::List — so a burst of fifty messages costs one fold, not fifty. Community::refresh keeps its own dirty follow-up.
  • Page verdicts (Message) are delivered immediately, inside the window, so an EOSE is never held up by a burst on another subscription.

The live REQ stays

CommunityRegistry::sync_subscriptions keeps installing one REQ per community over state.relays (ReqTarget::manual, never auto), and it keeps that subscription alive: a long-lived subscription is re-sent by the SDK after a reconnect and after a successful AUTH, which is exactly the "resync on socket reopen" behaviour the reference client implements by hand.

What changes is the filter: it now asks for both wrap kinds, includes private channels (§5), and carries a window and a bound:

/// What a community with no history in the database asks for: everything a relay
/// stores, bounded per relay so a cold start is not a whole plane.
const LIVE_REPLAY: usize = 500;

impl Window {
    /// The window a channel is opened with.
    ///
    /// Nothing held: ask wide, the round pages down from there.
    /// Something held: ask only for what is new, plus an overlap for the seam.
    pub fn opening(cursor: ChannelCursor) -> Self {
        match cursor.newest_ms {
            Some(newest_ms) => Window {
                since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
                until_ms: None,
            },
            None => Window::default(),
        }
    }
}

/// The window a community's standing subscription opens with.
///
/// One REQ covers every channel, so the floor is the **oldest** held cursor:
/// starting any newer would skip a channel's new region. A channel with no
/// cursor is left to its own round. `since = None` only when nothing is held.
pub fn live_window(state: &CommunityState) -> Window {
    let floor = state
        .channels
        .iter()
        .filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
        .min();

    match floor {
        Some(floor) => Window {
            since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
            until_ms: None,
        },
        None => Window::default(),
    }
}

The floor is computed when the REQ is installed (on open, on a plane change, on a signer change) and is not recomputed as cursors advance: a live REQ that is re-subscribed on every cursor merge would replay the seam on every message, and the region it did not cover is the round's job, not the live REQ's. Note that a since bounds only the stored replay; live events arrive whatever it is, so a channel with no cursor still receives new messages and gets its history from its own round.

3. A channel round over page subscriptions

Community::sync_channel(&self, channel, intent, cx) keeps its shape and its coalescing (Round { running, queued, waiters }, one round per channel, a request that arrives mid-round re-runs it once), and the panel keeps its two intents:

pub enum Intent {
    /// Opening a channel: newest data, the bridge, then a bounded older walk.
    CatchUp,
    /// Scrolling up: continue older history, at most `pages` pages.
    Older { pages: usize },
}

What changes is the transport. A page is now:

  1. the round registers a flume::Sender<PageReport> in the page registry under the page's id, before the REQ goes out;
  2. it installs one REQ per page over the community's relays (client.subscribe(ReqTarget::manual(..)).with_id(page_id).close_on(ExitOnEOSE + PAGE_TIMEOUT)), after checking each relay is in the pool (the pool refuses a target it does not hold, and would fail the whole REQ);
  3. the pump delivers one report per relay that settles (EOSE, or a non-auth CLOSED); the round waits until every relay it asked has settled or PAGE_TIMEOUT passes, then unregisters;
  4. it reads the page from the database with the same filter the REQ used.

A relay the pool does not hold is settled as refused up front rather than failing the page: its region was never read, so it blocks exhaustion like any other dead relay.

Rules carried over from phase 1, none of which are negotiable:

  • A page boundary is exclusive. until = oldest_seen_ms - 1, so consecutive pages never share an event and the walk terminates.
  • exhausted is earned. Only raw > 0 plus a bottomed-out page on every relay that answered may set it. An all-empty page sets failed, so the next round re-asks the same region instead of sealing the channel at "no more history".
  • A relay that never answered (deadline) or refused (any CLOSED that is not auth-required) is out of the walk and blocks exhausted; it is counted in Progress.errors.
  • auth-required is not a failure. The relay stays in the walk and the page waits for the re-issued REQ's EOSE. Only AuthenticationFailed settles a relay as refused, and it is surfaced.
  • One cursor, one filter per page. The database is not per relay, so per-relay cursors do not exist; a relay is a source that fills the database.
  • newest_ms advances only on a complete round (!newest.failed && bridge.exhausted), oldest_ms only walks down on a complete page, and exhausted is cleared by a rekey.

The cold/warm distinction is now the window rule rather than a special case:

State of the channel Window
no cursor, nothing cached (cold open) Window::openingsince = None: subscribe for everything the relays have, then page down
cursor with newest_ms (warm open) Window::openingsince = newest - CURSOR_OVERLAP: new data only
cursor with oldest_ms Window::older_than(oldest_ms): older data, on demand
a hole between saved.newest_ms and the newest seen Window::between(..): the bridge

So an open does two things, and they follow the same rule: it makes sure the live REQ is installed — wide when nothing is held, which is where "subscribe to get all data" comes from — and it runs a round whose newest pass asks only for the region the live REQ did not already cover (a warm open therefore replays nothing: the REQ is already streaming).

And the passes fall out of that rule:

  • Newest pass (CatchUp): one page at Window::opening(saved). Cold, this is "get all data"; warm, this is "get only new data".
  • Bridge: while the previous page was full and still above saved.newest_ms, keep walking down with until = oldest - 1, bounded by CATCH_UP_PAGES. A short page ends it, which is what lets newest_ms advance.
  • Older pass: resume at saved.oldest_ms.or(oldest_seen) and walk down, bounded by CATCH_UP_PAGES on a catch-up and LOAD_OLDER_PAGES on a scroll-up.

Sizes stay the reference numbers: PAGE_WRAPS = 50, CATCH_UP_PAGES = 20, LOAD_OLDER_PAGES = 6, CURSOR_OVERLAP = 60s.

Known limitation, unchanged and deliberately deferred: the bridge is bounded by CATCH_UP_PAGES, so an offline burst larger than CATCH_UP_PAGES * PAGE_WRAPS (1,000 wraps) is not repaired by one round and does not heal later (the bridge restarts from the same point). Fixing it needs a fourth cursor field recording how far a partial repair walked. One cheap improvement belongs in phase 2b: skip the older pass entirely when saved.exhausted is already set.

4. The read path

Unchanged from phase 1, moved intact:

pub struct Timeline {
    /// Oldest first, ready for a bottom-aligned list.
    pub messages: Vec<ChatMessage>,
    pub has_more: bool,
}

pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize, cx: &App)
    -> Task<Result<Timeline>>;
  • Reads limit + 1 rows to compute has_more, plus the side-event budget over the same span (SIDE_EVENT_FACTOR = 4), folds with cord03::fold (unchanged — it already resolves edits, deletes and reactions), reverses, returns.
  • TIMELINE_PAGE = 100 is the first paint; older history stays on disk and comes back when the user scrolls up.
  • The panel's only read is timeline.

5. Live completeness

  • Private channels get a plane. sync::planes derives it from the held key (channel_secret already knows the rule); a private channel is subscribed and folded exactly like a public one (finding 5).
  • Both wrap kinds. The filter asks for KIND_WRAP and KIND_WRAP_EPHEMERAL in the live REQ and in every page REQ, and the pump routes both (finding 6).
  • Community relays only. The live REQ and every page REQ use ReqTarget::manual over state.relays; a relay outside the community is never asked (finding 10).
  • Expired rows are swept on the open and round cadence with cache::purge_expired (finding 12).
  • A stable cache key. One local signing key persisted in the config dir replaces LazyLock::new(Keys::generate) (finding 11); the same rumor cached twice then has one addressable coordinate, so save_event replaces its predecessor instead of adding a copy per app run.

6. Held epochs and rekey adoption (phase 3)

The client has to be able to read the epochs it is supposed to read, and learn the next ones:

  • ChannelKeyRef gains #[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey> where HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<u64> }, and CommunityState gains the same shape for roots (HeldRoot { epoch, key, control_pk, retired_at }). channel_secretreturns every held epoch,history::pagealready takes&[(Epoch, [u8; 32])], and sync::refreshstops overwriting a held key in place and pushes the old one ontopriors`.
  • retired_at is a read cutoff. The reference retires the key a rotation steps off at the rotation's own publish time: nothing sealed under that key after it is read again. The read side needs the same floor: a wrap at epoch e whose created_at is past priors[e].retired_at is refused, at both the page and the fold.
  • The rekey watch is a subscription too: one long-lived REQ per community over its relays for authors(rekey pseudonyms), kinds([KIND_WRAP]), where the authors are cord06::rekey_group(RekeyScope::Base, held_root, id, epoch + 1) and — for every private channel and every held root (a Refounding seals channel rekeys under the prior root) — cord06::rekey_group(RekeyScope::Channel(channel), held_root, channel, epoch) for epoch in held_epoch + 1 ..= held_epoch + REKEY_LOOKAHEAD (8).
  • Adopt strictly, one epoch at a time, off the key actually held: cord01::open_wrapcord06::parse_rekey_chunkcord06::collect_rotationsis_completerotation.continuity(held_epoch, held_key) must be Extends (Gap is fetched, never waived; Fork resolves with cord06::fork_winner's lowest-key rule) → cord06::find_my_blobs + open_blobKeyDelivery.
  • Persist per delivery, then re-install the subscription (the plane set changed), clear that channel's exhausted, and run a CatchUp round for it.
  • Two terminal states, both persisted and both shown, never rendered as an empty timeline: removed (cord06::am_i_removed on a complete rotation at/after my join whose rotator outranks me — added_at_ms and rekey_authorized already carry the comparison) and stranded (a complete rotation ahead of my epoch that predates my join and carries no blob for me, which says the invite link is out of date).

7. Honest states (phase 4)

  • 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).

8. community_ui: reach the older rows

Landed in phase 1, unchanged by the re-layering: per-channel rows, has_more, loading, FollowMode::Tail instead of scroll_to_end(), load-older from the scroll handler plus an always-index-0 "Load earlier messages" row, prepend via splice(1..1, n), append via splice(at..at, n), and in-place merging unless the newest window no longer reaches the rows on screen.

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.

Still deferred: per-channel timeline state (switching back re-reads), and the MAX_TIMELINE_ROWS trim (trimming the oldest rows fights load_older, which prepends at the same end — the reader would oscillate).

9. The fold stops being O(history) (phase 2b) — landed

sync::fold used to open every wrap in the community's planes on every inbound wrap: channel wraps were re-opened only to cache_rumor them again and to observe their authors, which the database already holds as decoded rows (the cached row carries the author in a p tag, the message time in created_at, and the wrap it came from in an e tag). Finding 14.

What landed is smaller than the first sketch and needs no new queue:

  • cache::wrapper_index(client, channel) reads the cached rows back as EventId -> Observed { author, at_ms }, keyed by the wrap they were opened from.
  • fold opens a channel wrap only when its id is not in that index; an already-cached wrap is observed from the row instead. A wrap is therefore opened once, on the way in — by a round for a page, by the first fold after it arrives for the live wire — and never again.
  • The control and guestbook planes are still read from wraps (they are small, and cord02::fold_control is a fold over all editions by construction).

The fold still reads every wrap from the database (plane_filter, which is now its own unbounded filter separate from the live one); what it no longer does is NIP-44-open them all. The plan's per-channel work queue in the pump turned out to be unnecessary for that: the first fold after a live wrap already opens exactly the uncached wraps.

Order of work

Phase 2a — move the code (no behaviour change) — landed

  1. concord/src/store.rs split into concord/src/state.rs, community/src/cache.rs and community/src/history.rs per the table in §1; call sites updated (community/src/sync.rs, community/src/community.rs, community/src/lib.rs, and the cord02 test's crate::store::CommunityState). fetch_page is now history::page.
  2. The local cache key is stable (cache::LOCAL_KEYS is one fixed secret, not a per-process Keys::generate), so caching a rumor twice leaves one row. cache.rs carries the new caching_the_same_rumor_twice_leaves_one_row test.
  3. docs/concord-usage.md updated; concord dropped futures and nostr-memory and demoted smol to a dev-dependency; community gained futures and smol.
  4. Gate, all green with the phase-1 transport still in place: cargo test -p concord -p community (50 + 14), cargo clippy -p concord -p community -p community_ui --all-targets, cargo +nightly fmt --all --check (no diff in the touched files), cargo check -p workspace --all-targets. No behaviour changed beyond item 2.

Phase 2b — subscribe, then read the database — landed

  1. The pump (§2): route by subscription id for events, EOSE and CLOSED; batch within PUMP_WINDOW; both wrap kinds; auth-required never a failure.
  2. Page REQs (§3): the round registers a PageReport channel, installs one REQ per page over the community's relays, awaits the pump's reports with PAGE_TIMEOUT, reads the page from the database, and keeps the walk's rules (exhausted earned, all-empty is failed, newest_ms only on a complete round, skip the older pass when saved.exhausted).
  3. The live REQ (§2, §5): both kinds, private channels, the window rule with LIVE_REPLAY = 500, kept alive across reconnects.
  4. cache::purge_expired on the round cadence (every round is an open).
  5. The fold issue (§9).
  6. Gate: cargo test -p concord -p community (50 + 23), cargo clippy -p concord -p community -p community_ui --all-targets, cargo +nightly fmt --all --check, cargo check -p workspace --all-targets; plus the phase-1 manual bar and a dev-relay check that a warm open sends one REQ per relay carrying a since, and a cold open sends one with no since and pages down.

Three deviations from the revision-2 sketch, each forced by a constraint it did not account for:

  • A page id is opaque and resolved through a registry, not parsed. A NIP-01 id is capped at 64 characters and a community hex fills it, so concord-history/<community>/<channel>/<n> could never be sent. route_of therefore only parses the two standing ids, and PageRegistry maps a page id back to the round waiting on it.
  • The live window is per community and aggregates cursors. There is one REQ per community, so Window::opening(cursor) applies to a channel's newest pass while sync::live_window(state) derives the community floor as the oldest held cursor (a channel with no cursor forces since = None).
  • The fold needs no pump work queue. Skipping already-cached wraps through cache::wrapper_index already makes a wrap open once, on the way in, because the first fold after a live wrap is the one that opens it. See §9.

Also landed as the cheap win §3 promised: the older pass is skipped entirely when saved.exhausted is already set.

Phase 3 — epochs and rekeys

§6: HeldKey/HeldRoot + priors, retired_at as a read cutoff, the rekey watch over every held root, strict one-epoch-at-a-time adoption, re-subscribe + CatchUp after a delivery, removed versus stranded states.

Phase 4 — honest states and polish

§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, 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").

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 (§6) is next: it is what keeps a rekey from stranding history.

Phase 1 status (landed)

What phase 1 delivered, and what phase 2 replaces:

  • Window, WrapPage, history::page, Walk/Walker, the paged walk with an exclusive page boundary, ChannelCursor + monotonic merge persisted in CommunityState.cursors, Community::sync_channel/timeline, cache_rumor counting what opened, and the panel's load-older/follow-the-tail work. All of that survives; phase 2a moved it and phase 2b swaps its transport.
  • fetch_events is gone. Phase 1 already replaced it with subscribe-then-read-the-database: each page subscribed every answering relay to one filter (relay.subscribe(..).close_on(ExitOnEOSE + PAGE_TIMEOUT)), waited for that relay's EndOfStoredEvents(id), then read the page with client.database().query(filter). Revision 2's correction is where that lives (community, not concord) and who watches the notifications (the pump, not a loop inside a page).
  • auth-required was already excluded from the failure set in code, but revision 1's prose described it wrongly ("ends that relay's wait as a failure ... the next round retries"). The SDK re-issues the REQ under the same id after AUTH — for auto-closing subscriptions too — so the honest statement is: the relay stays in the walk and the page waits for its resubscribed answer.
  • Held epoch handling and rekeys are still open in §6, and honest empty states in §7. Private planes, the second wrap kind, the expired-row sweep, the stable cache key and the fold cost were open before phase 1 and are now landed (§5, §9).

Tests

Following the existing MemoryDatabase and Walk/serve_page test style. The 2b pieces are covered by unit tests that need no relay; the rows still marked outstanding are the ones that need a GPUI harness or two live accounts.

  • The walk: history::page continues past a page whose wraps none of them open; exhausted requires a short page after history; an all-empty round is failed and the next round re-asks the same region; until is exclusive and the walk terminates; a channel that already holds its newest page still pages older history; the bridge heals a hole; history pages across a rekey using retained prior keys. (Phase 1 landed the Walk half; the page-REQ half needs a relay.)
  • The pump — landed in 2b (community/src/lib.rs tests): an EndOfStoredEvents(id) settles exactly the page that owns id; a non-auth CLOSED settles its relay as refused; an auth-required CLOSED settles nothing; a page's event routes to nothing; a burst collapses to one signal per community; a shutdown stops the pump.
  • The windows — landed in 2b: Window::opening(default) has no since; Window::opening(saved) starts at newest - CURSOR_OVERLAP; Window::older_than never includes the boundary event; sync::live_window is wide cold and resumes at the oldest held cursor warm.
  • The subscription plan — landed in 2b: a private channel's plane appears when the key is held and is absent when it is not; plane_filter asks for both wrap kinds and addresses every readable plane. The page-REQ half (a warm open's REQ carries a since, a cold open's does not) still needs a dev relay.
  • The page registry — landed in 2b: a page that has already unregistered receives nothing.
  • The read path: the side-event budget folds an edit/delete/reaction older than the row window onto its message.
  • 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.)
  • A stable cache key: caching the same rumor twice leaves one row (the phase-1 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.)
  • 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.

Out of scope

Unread badges, notifications, message threads, pins, typing indicators and presence (21059 wraps are wired for routing here, not for those features), file and media rendering, moderation actions, and the community-management surfaces. crates/chat's DM path shares none of this code and is not touched; a later change can lift the page walk/cursor into a shared module if DMs grow the same paging.