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

594 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_message``MarkAsClosed`, 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 in `concord/src/store.rs`; phase 2a has
since moved each layer to where it belongs — the paths here are the current ones):
```
CommunityPanel::load community_ui/src/lib.rs:192
-> Community::sync_channel community/src/community.rs:218
-> sync_round community/src/community.rs:604
-> history::page community/src/history.rs (per-relay
-> ingest_page community/src/history.rs subscribe +
notification wait)
-> client.database().query(filter) (the page)
-> cache::cache_rumor community/src/cache.rs
-> Community::timeline community/src/community.rs:368
-> cache::query_rumors community/src/cache.rs
-> cord03::fold concord/src/cords/cord03.rs
live wire community/src/lib.rs:307 (sync_subscriptions)
-> sync::subscription_filter community/src/sync.rs:79 (kind 1059 only,
no since, no limit)
-> pump community/src/lib.rs:352 (drops every
Message and every
non-1059 event)
```
## 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. | open (phase 2b) | `community/src/sync.rs:63-66` |
| 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. | open (phase 2b) | `community/src/sync.rs:79-83`, `community/src/lib.rs:380-383` |
| 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. | open (phase 2b) | `community/src/cache.rs` (no call sites) |
| 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. | open (phase 2b) | `community/src/sync.rs:423-517` |
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 page****bridge** (`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::refresh``community::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/<self pk>` (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/<community hex>/<channel hex>/<n>` | 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 |
A single `route_of(&SubscriptionId) -> Option<Route>` parses the id back into
`Route::{List, Community(CommunityId), History { community, channel }, Rekey(community)}`,
so the pump routes by subscription id first and never has to guess from an event.
#### The pump
`CommunityRegistry::handle_notifications` becomes the only consumer of
`client.notifications()`, and it handles the message variants it drops today:
```rust
loop {
match notifications.next().await {
Some(ClientNotification::Event { subscription_id, event, .. }) => {
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.events.insert(id),
// A page and a rekey watch are read from the database later:
// the page when its relays settle, the rekey when the batch closes.
Some(Route::History { .. }) => {}
Some(Route::Rekey(id)) => batch.rekeys.insert(id),
None => {}
}
}
Some(ClientNotification::Message { relay_url, message }) => match *message {
RelayMessage::EndOfStoredEvents(id) => pages.settled(&id, relay_url, Settled::Replayed),
RelayMessage::Closed { subscription_id, message }
if !auth_required(&message) =>
{
pages.settled(&subscription_id, relay_url, Settled::Refused(message));
}
// auth-required: the SDK re-issues this REQ under the same id after
// AUTH, so the page keeps waiting for the resubscribed answer.
_ => {}
},
Some(ClientNotification::Shutdown) | None => break,
_ => {}
}
}
```
- **Kind 1059 and 21059 both route** (finding 6); the previous revision's
"drops every non-1059" check goes away entirely.
- The batch is closed after `PUMP_WINDOW` (200 ms, the reference app's value) of
quiet, and 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.
- `pages` is the registry's page registry:
`HashMap<(CommunityId, ChannelId), flume::Sender<PageReport>>`, registered by a
round before it subscribes and keyed off the id `route_of` parses back.
`PageReport { id, relay, outcome }` carries facts; the walk decides what they
mean. Registration happens once per round (not per page), and the round matches
reports by subscription id.
#### 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**:
```rust
/// 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 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.
### 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:
```rust
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 its `PageReport` sender for `(community, channel)`;
2. it installs **one REQ per page** over `state.relays`
(`client.subscribe(manual(relays, filter)).with_id(page_id).close_on(ExitOnEOSE + PAGE_TIMEOUT)`),
tagged with a unique `history_subscription_id`;
3. it awaits the pump's reports for that id until every relay it asked has
settled (EOSE, or a non-auth CLOSED) or `PAGE_TIMEOUT` passes;
4. it reads the page **from the database** with the same filter the REQ used.
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::opening``since = None`: subscribe for everything the relays have, then page down |
| cursor with `newest_ms` (warm open) | `Window::opening``since = 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:
```rust
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_secret` returns every held epoch,
`history::page` already takes `&[(Epoch, [u8; 32])]`, and `sync::refresh` stops
overwriting a held key in place and pushes the old one onto `priors`.
- **`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_wrap` → `cord06::parse_rekey_chunk` → `cord06::collect_rotations`
→ `is_complete` → `rotation.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_blob` → `KeyDelivery`.
- 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)
`sync::fold` currently opens every wrap in the community's planes on every inbound
wrap: channel wraps are 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 and the message time in `created_at`). Finding 14.
The shape of the fix, in the same phase because it is the same code path:
- Channel wraps are opened **once, on the way in** — the round does it for pages,
the pump does it for live wraps through a per-channel work queue — instead of
every fold.
- The fold keeps reading the control and guestbook planes from wraps (they are
small, and `cord02::fold_control` is a fold over all editions by construction).
- Member observation comes from the cached rows or from an incrementally
maintained per-channel map, not from re-opening history.
## 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
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, `Window::opening(saved)`
with `LIVE_REPLAY = 500`, kept alive across reconnects.
4. `cache::purge_expired` on the open and round cadence.
5. The fold issue (§9).
6. Gate: the phase-1 manual bar, plus a check against a dev relay that a warm open
sends one REQ per relay carrying a `since`, and a cold open sends one with no
`since` and pages down.
### 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 is invisible;
phase 2b is what makes "open a community" a subscription and a database read.
## 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, rekeys, private planes, the second wrap kind, honest empty
states, the expired-row sweep, the stable cache key and the fold cost were all
open before phase 1 and are still open in §5, §6, §7 and §9.
## Tests
Following the existing `MemoryDatabase` and `Walk`/`serve_page` test style:
- 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.
- The pump: 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 21059 event routes by subscription id; a burst within one
window produces one fold.
- The windows: `Window::opening(default)` has no `since`; `Window::opening(saved)`
starts at `newest - CURSOR_OVERLAP`; `Window::older_than` never includes the
boundary event.
- The subscription plan: a private channel's plane appears when the key is held
and is absent when it is not; the live filter asks for both wrap kinds; the
live REQ targets the community's relays only.
- 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.
- 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).
- 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.