1330 lines
83 KiB
Markdown
1330 lines
83 KiB
Markdown
# 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.
|
||
|
||
**Revision 3.** One more correction, from a review of what revision 2 landed:
|
||
|
||
4. **Time is a type, not a `u64` with the unit in its name.** The read path held
|
||
three different units in the same `u64`: `HeldKey`/`HeldRoot.retired_at` held
|
||
seconds (but were compared with a `Timestamp` through `.as_secs()`),
|
||
`ChannelCursor` held milliseconds that were only ever seconds multiplied by a
|
||
thousand, and `CommunityState.channel_cuts` held epochs. Every filter boundary
|
||
divided by a thousand on the way out. §10 types the time fields (`Timestamp`,
|
||
`Epoch`) and leaves a millisecond only where a millisecond is real.
|
||
|
||
**Revision 4.** The six phases landed and the symptom was still there: a
|
||
community showed its full history, its full roster, and never a new message. The
|
||
audit that followed found the read path asking for addresses nobody writes to,
|
||
plus two smaller reasons the same shape comes back (§11):
|
||
|
||
5. **A public channel's plane follows the ROOT epoch, not the epoch its material
|
||
names.** `held_keys` derived every held root's plane at the *channel's*
|
||
recorded epoch, and `channel_secret` sealed at it, so after a Refounding the
|
||
current root's plane was asked for and written to at the retired epoch — an
|
||
address no other client reads. The channel kept everything written before the
|
||
rotation and received nothing after it, while the Control and Guestbook planes
|
||
(which derive from the roots directly) stayed current: the roster looked
|
||
complete while the room looked dead.
|
||
6. **The walk never reported its floor.** `Walk::accept` computed the page's
|
||
oldest wrap into a local and never into `Walk::oldest`, so every page reported
|
||
`oldest: None`. The older pass had no point to resume below (scroll-up could
|
||
only re-read the local cache), the bridge never ran (a burst larger than one
|
||
page left a hole the cursor then sealed as covered), and `exhausted` could
|
||
never be earned.
|
||
7. **Two ways for the live path to go quiet are closed.** A cursor is clamped to
|
||
the local clock, so a peer's future stamp cannot bound every later filter
|
||
below a region that has not happened; and a community whose relays go silent
|
||
re-issues its standing REQ, because a subscription that died without saying so
|
||
is indistinguishable from a quiet one.
|
||
|
||
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, phase 4 made an empty or unreadable room say so,
|
||
phase 5 typed the times they all compare, revision 4 fixed the plane a public
|
||
channel is read at once its root has rotated):
|
||
|
||
```
|
||
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)
|
||
-> rekey::watches community/src/rekey.rs (base next epoch +
|
||
a channel window per
|
||
held root)
|
||
-> pump community/src/lib.rs:433 (routes by subscription
|
||
id, batches within
|
||
PUMP_WINDOW, settles
|
||
pages by EOSE/CLOSED)
|
||
-> rekey::adopt community/src/rekey.rs (reads the rekey wraps
|
||
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
|
||
|
||
| # | 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. | 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. | 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` |
|
||
| 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` |
|
||
| 15 | **A public channel's plane was derived at the channel's recorded epoch for every held root**, so after a Refounding the current root's plane was subscribed, paged and sealed at the retired epoch: no live events, no new pages, and no cursor ever written for the channel — while the history of the prior epoch and the wholeControl and Guestbook planes still read. | fixed in revision 4 (§11) | `concord/src/state.rs` (`held_keys`), `community/src/community.rs` (`channel_secret`) |
|
||
| 16 | **The walk never reported its floor** (`Walk::oldest` was computed into a local), so the older pass never ran, the bridge never healed a gap, and `exhausted` was never earned. | fixed in revision 4 (§11) | `community/src/history.rs` (`Walk::accept`) |
|
||
|
||
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; 15-16 are why a rotated community reads
|
||
as a complete archive that stopped receiving.
|
||
|
||
## 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` (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 | `rekey-<community hex, first 32>` (opaque) | 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 added the `Rekey` route the same way, through a parallel
|
||
`rekey::WatchRegistry` that maps an opaque `rekey-<32 hex>` id back to its
|
||
community: the whole id is sent to the relay, so a page's rule applies — an id
|
||
cannot carry a community hex and stay within the cap.
|
||
|
||
#### 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`):
|
||
|
||
```rust
|
||
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:
|
||
|
||
```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 {
|
||
Some(newest) => Window {
|
||
since: Some(newest - CURSOR_OVERLAP),
|
||
until: 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)
|
||
.min();
|
||
|
||
match floor {
|
||
Some(floor) => Window {
|
||
since: Some(floor - CURSOR_OVERLAP),
|
||
until: 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:
|
||
|
||
```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 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 - 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` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
||
`oldest` 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` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
||
| cursor with `oldest` | `Window::older_than(oldest)`: older data, on demand |
|
||
| a hole between `saved.newest` 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`, keep walking down with `until = oldest - 1`, bounded by
|
||
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest` advance.
|
||
- **Older pass**: resume at `saved.oldest.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 one per held epoch
|
||
(the current key plus every `priors` entry), and a public channel one per held
|
||
root, so a rotation never blanks a plane; 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) — **landed**
|
||
|
||
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<Timestamp> }`, 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).
|
||
|
||
**What landed.** `HeldKey { epoch, key, retired_at }` and
|
||
`HeldRoot { epoch, key, control_pk, retired_at }` live in `concord::state`;
|
||
`ChannelKeyRef` gained `priors` and `CommunityState` gained `held_roots`,
|
||
`channel_cuts`, `removed_at` and `stranded`. `CommunityState::roots()` and
|
||
`held_keys(channel)` are the read surface: a private channel returns its current
|
||
key plus every prior, a public channel one entry per held root. `sync::planes`
|
||
derives a plane for each, `history::page` takes `&[HeldKey]` and refuses a wrap
|
||
sealed under a retired key after its cutoff, and `sync::fold` applies the same
|
||
cutoff. `sync::refresh` no longer overwrites a held key in place — it pushes the
|
||
superseded one onto `priors` — and a recorded `channel_cuts` entry keeps a stale
|
||
grant from merging a removed channel back.
|
||
|
||
The wire side is `community/src/rekey.rs`: `watches(state)` builds the base
|
||
next-epoch address plus a `1..=8` channel window under every held root,
|
||
`watch_filter` is the one standing REQ over the community's relays, and `adopt`
|
||
reads the delivered wraps back out of the database and walks each scope forward.
|
||
`CommunityRegistry` installs the watch beside the live REQ when a community's
|
||
plane set changes, routes its events to `Signal::Rekey` through the pump, and
|
||
`Community::merge_adoptions` folds the result in, persists it, clears the moved
|
||
cursors' `exhausted` verdicts, and runs a `CatchUp` for the active channel.
|
||
|
||
Three deviations from the sketch above, all of them smaller than planned:
|
||
|
||
- **The rekey watch is one subscription per community, not per scope.** The base
|
||
and every private channel ride the same `authors` filter; the wraps are
|
||
addressed by pseudonym, so one REQ covers them all and one `watch_filter`
|
||
rebuild covers every adoption. The id is opaque (`rekey-<32 hex>`) and resolves
|
||
through `rekey::WatchRegistry` for the same 64-character reason a page id does.
|
||
- **A removal is judged without continuity, a strand with it.** Adoption requires
|
||
`Extends`; the removal/strand decision considers every complete authorized
|
||
rotation at the target epoch **except** one whose `prevcommit` forks (which is
|
||
neither, and acting on it is how a member ends up on a fork). A member who
|
||
missed a link is still removable — the reference's own channel watcher treats
|
||
"past my epoch" as the removal test.
|
||
- **Adoption chains within one pass.** `walk` loops `held + 1` while each step is
|
||
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.
|
||
|
||
#### The writer (phase 6) — **landed**
|
||
|
||
Phase 3 gave the client a receiver; phase 6 gives it a hand. `rekey::rotate`
|
||
builds the rotation from the key the client actually holds and publishes it, and
|
||
`Community::rotate` is the entry a moderation surface will call:
|
||
|
||
- **One plan, drawn in the protocol crate.** `cord06::plan_rotation(scope, epoch)`
|
||
mints what the rotation delivers — a `Refounding` (new root and control pair)
|
||
for a base scope, a fresh key for a channel — beside `plan_refounding`, which is
|
||
now the base arm of it. No caller ever holds a rotation secret before the
|
||
rotation exists.
|
||
- **Authority first, and the same authority the receiver applies.** `Rewrite::authorized`
|
||
runs `cord06::rekey_authorized` under `permissions(scope)` — the same list
|
||
`adopt` now walks with, shared instead of written twice — so the client cannot
|
||
publish a rotation it would not itself adopt. `Community::rotate` adds the three
|
||
states the role fold cannot see: a removal, a strand and a ban.
|
||
- **One blob per recipient, and the rotator is one of them.** A rotation that
|
||
delivered no blob to its own rotator would strand them, so the writer refuses
|
||
one. Staff get the new Control Plane root beside the key, everyone else gets the
|
||
key alone.
|
||
- **A refounding carries its heads.** `carry_heads` reads the control editions
|
||
back out of the store, picks the ones the settled floors name, and `compact`s
|
||
them onto the new epoch's groups. Without it the new Control Plane starts empty
|
||
and the next reader that does not hold the old root folds no roles, metadata or
|
||
banlist at all. (`compact` re-signs nothing, so this only works on the plaintext
|
||
seals the Control Plane already uses.)
|
||
- **Then it adopts itself, through the ordinary receiver.** The chunks are saved
|
||
into the local database as they are sent, so `Community::rotate` calls the same
|
||
`rekey::adopt` the watch calls instead of adopting by construction: one path for
|
||
every rotation, whoever wrote it. Publish failures are logged per relay and do
|
||
not change the held state, so a rotation that only reached some relays still
|
||
leaves this client consistent — which is the same asymmetry the reference has.
|
||
- **The receiver now keeps the signing root it is handed.** `BaseAdoption`
|
||
carries the delivered `control_root` through to `state.control_root`; before
|
||
this, a refounding's root was dropped at the receiver and a staff member went on
|
||
signing under the epoch they had left.
|
||
|
||
What is still not here: the trigger. Kicking a member, choosing a channel's
|
||
remaining audience, and rendering any of it is the moderation and
|
||
community-management surface §8 keeps out of scope, so `Community::rotate` is an
|
||
API with no caller in the app yet — and `Rewrite.recipients` is the caller's to
|
||
name, because a private channel's audience is in each member's own invite and not
|
||
in the local state. **And the new epoch's roster is not seeded:** CORD-02 §5 has
|
||
the refounder publish a snapshot of the present members into the Guestbook it
|
||
just minted, which is the only way a client that joins *after* the rotation ever
|
||
learns who was already there. `cord02::guestbook::build_snapshot_chunks` is
|
||
implemented and unobliged; a refounding published from here would leave every
|
||
later joiner with an inferred roster (§12c).
|
||
|
||
### 7. Honest states (phase 4) — **landed**
|
||
|
||
"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
|
||
|
||
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 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
|
||
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.
|
||
|
||
### 10. Time is a type: `Timestamp`, not padded milliseconds (phase 5) — **landed**
|
||
|
||
The read path compared times through a bare `u64` in three units, and each
|
||
boundary paid for the confusion:
|
||
|
||
| Value | Held | What it cost |
|
||
| ----- | ---- | ------------ |
|
||
| `HeldKey.retired_at`, `HeldRoot.retired_at`, `Plane.retired_at` | seconds | compared with `wrap.created_at.as_secs()`, written as `at_ms / 1000` |
|
||
| `ChannelCursor.newest_ms`/`oldest_ms`, `WrapPage`, `Window`, the `Walk` bounds | milliseconds, always `secs * 1000` | `* 1000` on every accepted page, `/ 1000` on every filter |
|
||
| `CommunityState.channel_cuts` | epochs | `Epoch` unpacked to `u64` and rewrapped at the boundary |
|
||
| `added_at_ms`, a rumor's `at_ms` | milliseconds, genuinely | — |
|
||
|
||
What landed:
|
||
|
||
- `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor.{newest,oldest}`,
|
||
`WrapPage.{newest,oldest}`, `Plane.retired_at`, `Window.{until,since}` and the
|
||
`Walk` bounds are `Timestamp`s. A window bound and a wrap's `created_at` are now
|
||
the same type, so `read_under` and `Plane::accepts` compare them directly,
|
||
`wrap_filter`/`live_filter` hand them to `until`/`since` unchanged, and the
|
||
page's exclusive boundary is written `oldest - 1` instead of `oldest_ms - 1`
|
||
truncated back to seconds.
|
||
- `CURSOR_OVERLAP` is a `Duration` (`60s`), not `60_000`, so
|
||
`newest - CURSOR_OVERLAP` reads as what it is.
|
||
- `channel_cuts` is `BTreeMap<ChannelId, Epoch>`, which is what
|
||
`rekey::Adoptions::cuts` already carried.
|
||
- **The two millisecond fields stay milliseconds.** A rumor's time is genuinely
|
||
finer than a wrap's second-granular `created_at`: CORD-01 carries the sub-second
|
||
offset in an `ms` tag and `resolve_ms_strict` puts it back, so a reader's
|
||
`at_ms` and `timeline`'s `before_ms` keep it. The cord02 list document's
|
||
`added_at`/`removed_at` keep it too, and not merely for fidelity: `added_at` is
|
||
an ordering key judged against a tombstone's `removed_at` under a strict `>`,
|
||
so narrowing both to seconds could tie a rejoin with the leave that preceded it.
|
||
- **A stored cursor is retired, not reinterpreted.** A document written when the
|
||
boundaries were milliseconds would deserialize into a `Timestamp` far in the
|
||
future, and its `exhausted` would then wedge the older pass for good. The map's
|
||
serde key changed (`cursors` → `channel_cursors`), so such a record is dropped
|
||
and the next round rebuilds the cursor from the wraps it reads;
|
||
`a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted` pins
|
||
both halves of that. `retired_at` needed no such treatment, because it was
|
||
already written in seconds.
|
||
|
||
No behaviour changes with it: the walk paged the same regions before and after,
|
||
because every millisecond it held was a second multiplied by a thousand.
|
||
|
||
### 11. A Refounding moves a public channel's plane (revision 4) — **landed**
|
||
|
||
The symptom that survived every phase: a channel showed its history and its room
|
||
members, and never a new message — not while the panel was open, and not after
|
||
reopening it, while another client was writing to the same channel. Three
|
||
findings, in order of how much they explain.
|
||
|
||
#### 11a. The plane a public channel is read and written at
|
||
|
||
A public channel holds no key of its own: CORD-03 §1 derives its Chat Plane from
|
||
the **community root**, at the **root epoch** — `channelGroupKey(root, channel,
|
||
rootEpoch)`. A Refounding moves the root to a new epoch, so every public
|
||
channel's plane moves with it. The reference therefore keeps one stream per held
|
||
**root** epoch and writes to the newest (`channelsView`'s `rootStreams` with
|
||
`current: rootStreams[0]`), which is also what makes history survive the
|
||
rotation.
|
||
|
||
This code paired every held root with the channel's *recorded* epoch — the root
|
||
epoch the channel's material was published at — and `channel_secret` sealed at
|
||
the same value. At genesis the two agree, so nothing looked wrong; after a
|
||
rotation they diverge, and every plane the client asked for was addressed at the
|
||
retired epoch:
|
||
|
||
| Plane | Derived from | After a Refounding |
|
||
| ----- | ------------ | ------------------ |
|
||
| Channel (`channel_group_key`) | `held_keys` | current root **at the old epoch** — an address nobody writes |
|
||
| Control (`control_group_key`) | `state.control_pks` epochs | current |
|
||
| Guestbook (`guestbook_group_key`) | `roots()` epochs | current |
|
||
|
||
That shape is the whole report: the roster, the channel list and the old history
|
||
were all right, the room received nothing, and because the newest pass came back
|
||
empty the round read as failed, so no cursor was ever written for that channel.
|
||
The community's own messages went to the same unreachable plane — readable by us,
|
||
invisible to everyone else.
|
||
|
||
**Fixed:** `held_keys` pairs each root with **its own** epoch
|
||
(`HeldKey { epoch: root.epoch, key: root.key, retired_at: root.retired_at }`), and
|
||
`channel_secret` seals a public channel at
|
||
`(state.root_epoch, state.community_root)` — the newest held root, the one the
|
||
reference calls `current`. The two halves move together on purpose: reading where
|
||
we write, and writing where the rest of the protocol reads, is the same statement.
|
||
|
||
#### 11b. The page's floor
|
||
|
||
`sync_round` uses `history::page`'s report three ways: the newest pass's `oldest`
|
||
is where the older pass resumes (`resume = saved.oldest.or(newest.oldest)`), the newest
|
||
pass's `oldest` is the top of the bridge (`Window::between(saved.newest, oldest)`),
|
||
and a page that reached the bottom earns `exhausted` so later rounds can stop
|
||
paging. `Walk::accept` computed that value into a local and never assigned
|
||
`Walk::oldest`, so every page reported `oldest: None` and all three consequences
|
||
followed at once: the older pass never ran (scrolling up could only ever re-read
|
||
the local cache), the bridge never ran (a burst larger than one page left a hole
|
||
that the next cursor advance then sealed as covered), and the bottom was never
|
||
reached. The persisted state said so plainly — no cursor carried an `oldest`, and
|
||
every `exhausted` was false.
|
||
|
||
**Fixed:** `accept` folds the page's floor into `Walk::oldest`.
|
||
|
||
#### 11c. Two ways for the live path to go quiet, closed
|
||
|
||
- **A cursor is clamped to the local clock.** A wrap stamped in the future — a
|
||
peer's skewed clock, or a hostile stamp — used to become
|
||
`ChannelCursor.newest`, and the cursor is persisted, so every later REQ for that
|
||
channel would open with `since` ahead of the present. Relays apply `since` to
|
||
live events as well as to the stored replay, so the channel goes deaf for as
|
||
long as the stamp leads and no restart heals it. The reference clamps at exactly
|
||
this point (`WireSync`'s `writeCursor`: an event stamped in the future "must not
|
||
drag the cursor past `now`"). `clamped` bounds `newest` both on the way into the
|
||
document and on the way into a filter, so a cursor already stored past `now`
|
||
heals on the next round instead of staying deaf.
|
||
- **The standing REQ is re-issued when a community goes quiet.** A relay can end
|
||
a subscription without a reason the SDK acts on, and nothing here re-installed
|
||
one whose plane set had not moved, so a community whose subscription died looked
|
||
exactly like a quiet community until the next launch. The reference rotates
|
||
every relay's REQ after 90s of silence for this reason ("never trust one
|
||
subscription for long"); `CommunityRegistry::tick` now does the same, because an
|
||
accepted REQ that yields nothing and a REQ that was never accepted look
|
||
identical from here. The re-issue resumes from the current window, so the seam
|
||
is replayed rather than lost.
|
||
|
||
What is deliberately still not here: a future-dated message is folded and shown
|
||
rather than held out of the timeline until its time comes (the reference's
|
||
`FUTURE_HOLD_MS`). The clamp already keeps such a stamp from bounding a filter;
|
||
hiding it is a display decision this client has not made.
|
||
|
||
And one gap this revision leaves open, because it is a different symptom (depth,
|
||
not freshness) and it cannot be closed honestly from what the code has at hand:
|
||
`sync::refresh` overwrites `community_root`/`root_epoch` from the community
|
||
list's material without retiring the root it replaces, and `held_roots` is
|
||
populated only by `rekey::adopt`. A client that learns of a Refounding from a
|
||
re-materialized list entry rather than from a rekey blob the watch delivered
|
||
therefore loses the prior root, which is exactly the history the reference keeps
|
||
in `heldRoots` ("every held epoch stays in the decode set"). The messages are
|
||
still on screen — they are in the local cache — but paging *below* them against
|
||
a relay needs that plane. Retiring a root correctly needs the rotation's publish
|
||
time; a list entry carries only its own `added_at`, so the honest fix is for the
|
||
re-materialization to carry the prior root the way an adoption does, not to guess
|
||
a cutoff here. **Closed in revision 5 (§12c)**, on the same terms: a root the
|
||
List moved past is retained as a key we were given, and the cutoff stays absent
|
||
rather than guessed.
|
||
|
||
### 12. Two reports from one client (revision 5) — **landed**
|
||
|
||
The plane fix worked — history, the roster and new messages all arrived — and the
|
||
same client then reported two things it could see: *"when the message list
|
||
updates, the order isn't by timestamp anymore"*, and *"the member list still
|
||
isn't showing full members like the other client"*. Two independent bugs, both
|
||
confirmed against the client's own database before anything was changed.
|
||
|
||
#### 12a. Rows are placed where the fold puts them, not appended
|
||
|
||
The panel treated a read as newer than the rows on screen: new ids were appended
|
||
to the end (`apply`) and the older page was spliced onto the front (`prepend`).
|
||
Both hold only while every read is *newer than everything shown* or *older than
|
||
everything shown* — and reads are neither. A catch-up round heals a gap by
|
||
caching history sealed long ago, the bridge pass fills the region *above* the
|
||
cursor's newest, and a message delivered late lands with its own (older) time. The
|
||
client's own database shows the ground for it: the room held 1,176 cached rows, so
|
||
every window read is a *selection* of its history, and a round finishing while the
|
||
panel is open hands `apply` rows older than the newest row already on screen. The
|
||
same read also reaches `apply` twice — once through `Updated` and once through
|
||
`load_older`'s own re-read — so whether a page of history landed at the top or the
|
||
bottom depended on which task ran first.
|
||
|
||
**Fixed:** one `merge` for both paths. Rows already shown keep their position (an
|
||
edit replaces its row in place); a row that is new is inserted where the fold
|
||
would have put it — `partition_point` over `(at_ms, id)` ascending, the exact
|
||
order `Community::timeline` returns — and the list state is spliced at that index
|
||
instead of at the end. The order the panel shows is now a function of the data,
|
||
not of the order reads happened to land in.
|
||
|
||
One other lie about time went with it: `send` stamped `at_ms` as
|
||
`Timestamp::now().as_secs() * 1000`, and a rumor's `ms` tag is the *remainder*
|
||
within the second (`split_ms`), so every message this client sent carried `0` —
|
||
placed at the start of its second, up to 999 ms before it was written, and before
|
||
any message from another client in the same second. The reference stamps
|
||
`Date.now()`; `now_ms()` does the same, with the clock error propagated rather
|
||
than flattened.
|
||
|
||
#### 12b. A Refounding snapshot could never be honored
|
||
|
||
`sync::fold` called `guestbook::coalesce` with `None` as the snapshot authority,
|
||
and `coalesce` drops every snapshot chunk whose refounder is not that authority —
|
||
so **every** Refounding snapshot this client ever received was ignored, and the
|
||
roster fell back to the members it could infer: authors seen publishing, granted
|
||
npubs, and joins it had itself received.
|
||
|
||
The client's database shows exactly what that costs. Vector Community is at root
|
||
epoch 9 with no retained roots; its epoch-9 Guestbook holds 13 wraps — 11 joins,
|
||
1 leave, and **one snapshot naming 240 members**, authored by `d133ecb0…`. Folded
|
||
with no authority the roster comes out at **41 members**; folded with that one
|
||
snapshot honored it comes out at **247**. Nothing about the wraps is missing or
|
||
unreadable — only the authority to believe them was.
|
||
|
||
**Fixed:** the refounder is recorded where the protocol states it and honored
|
||
where CORD-02 §5 asks for it.
|
||
|
||
- `CommunityState.refounders` — the npubs whose rotation minted an epoch this
|
||
client verified. `guestbook::coalesce` now takes that set (`&BTreeSet`) instead
|
||
of an `Option`, which is also what the reference passes: the wire never says
|
||
which epoch's stream carried a chunk, so the authorities are unioned and the
|
||
residual (one held epoch's refounder accepted on another's snapshot) is
|
||
documented there and here. An empty set honors no snapshot, and genesis — where
|
||
no rotation minted anything — has no authority at all.
|
||
- `rekey::walk` records the rotator of every base step it adopts
|
||
(`Delivery.rotator` → `Adopted.refounders` → `BaseAdoption.refounders`), and
|
||
`merge_adoptions` folds them into the state. A rotation is the only place a
|
||
refounder is ever named, and it is verified the way an adoption already was:
|
||
continuity from the key held, a blob for us, and the rotator's rank.
|
||
|
||
Which raised the question the client's own state answered: it holds epoch 9 but
|
||
never adopted the 8 → 9 rotation (its `held_roots` is empty), because it learned
|
||
of the refounding from a re-materialized List entry. A rotation can only be read
|
||
from the root it stepped off, so the refounder of an epoch the client was away
|
||
for was unreachable. **Fixed in §12c**, and the two fixes are one story: the
|
||
refounder of the current epoch is recoverable precisely because the root before
|
||
it is retained.
|
||
|
||
#### 12c. A superseded root is history, not a secret
|
||
|
||
Three points, one rule — a root the community has rotated past stays held:
|
||
|
||
- `sync::refresh` no longer overwrites `community_root`/`root_epoch` from the
|
||
List's material without retiring the root it replaces, which is what lost the
|
||
prior root in the first place (the gap §11 closed on paper).
|
||
- `sync::load` likewise keeps the root the List's **seed** material names: the
|
||
List carries the community as it was when *we* joined, so that root is a key
|
||
this client was given. `merge_entry` already preserves the lowest-epoch
|
||
material as the seed, so a client that joined three rotations ago still has
|
||
three rotations' worth of read keys recoverable.
|
||
- `rekey::watches` now watches the epoch after **every** held root, not only the
|
||
current one, and `rekey::adopt` walks from every held root before it, keeping
|
||
the furthest adoption (the epoch the community is actually on). That is what
|
||
turns a retained root into a refounder: the 7 → 8 and 8 → 9 rotations are
|
||
still verifiable from the roots they stepped off.
|
||
|
||
The retained roots pay for themselves a second time: `held_keys` derives channel
|
||
planes from them, so public-channel history *below* the local cache becomes
|
||
pageable again for a rotated community — the depth gap §11 named, not just the
|
||
refounder.
|
||
|
||
What is honestly still missing, in the order it matters:
|
||
|
||
- **Recovery needs the old rotation chunks to still be on a relay.** A refounder
|
||
is only learnable from the rotation that named it; if the relays have dropped
|
||
the chunk for the epoch a client is on, the seeded members stay unknown until
|
||
the next Refounding — the client cannot verify a snapshot it has no rotation
|
||
for, and accepting one from an unverified npub would let any member inject
|
||
arbitrary npubs into everyone's roster.
|
||
- **A Refounding this client publishes seeds nobody.** `build_snapshot_chunks`
|
||
exists and the reference publishes one at every refounding ("present members
|
||
only, chunked at 400"); `Community::rotate` does not yet. It has no caller, so
|
||
nothing regresses today, but the first client that refounds without it leaves
|
||
every later joiner with an inferred roster.
|
||
- **`complete_memberlist` is still given no ban times.** The reference passes the
|
||
Control Plane's authorized ban history so that activity *before* a ban does not
|
||
resurface as membership after an unban; `fold_control` exposes the Banlist as a
|
||
set and no times, so an unbanned member's older activity counts as present here.
|
||
Nobody is lost by it — the failure mode is a member shown who left — and it is
|
||
the fold in `concord` that would have to grow the times.
|
||
- **The panel's order is untested.** The merge is a few lines and the crate has
|
||
no harness for a panel; the invariant it restores is the one `Community::timeline`
|
||
already promises, and it is asserted here by reading it against the fold, not
|
||
by a test.
|
||
|
||
## 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 — **landed**
|
||
|
||
1. The document (§6): `HeldKey`/`HeldRoot`, `ChannelKeyRef.priors`,
|
||
`CommunityState.held_roots`/`channel_cuts`/`removed_at`/`stranded`, and the
|
||
`roots()`/`held_keys()` read surface.
|
||
2. The read cutoff: `history::page` takes `&[HeldKey]` and refuses a wrap sealed
|
||
under a retired key after the rotation's publish time; `sync::fold` applies the
|
||
same rule from the channel's `priors`.
|
||
3. `sync::planes` derives planes from every held channel epoch and every held
|
||
root; `sync::refresh` preserves priors and honors recorded channel cuts.
|
||
4. The rekey watch and adoption (§6), `community/src/rekey.rs`, wired through the
|
||
pump's `Signal::Rekey` and a `WatchRegistry`.
|
||
5. Gate, all green: `cargo test -p concord -p community` (50 + 29),
|
||
`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`.
|
||
|
||
### Phase 4 — honest states and polish — **landed**
|
||
|
||
§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, was still open there (§6, closed in
|
||
phase 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 is what makes an empty or
|
||
unreadable room tell the truth.
|
||
|
||
### Phase 5 — typed time — **landed**
|
||
|
||
§10. `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor`, `WrapPage`, `Plane`,
|
||
`Window` and the `Walk` bounds are `Timestamp`s; `channel_cuts` is `Epoch`;
|
||
`CURSOR_OVERLAP` is a `Duration`; and the cursor map's serde key was retired so no
|
||
stored millisecond cursor is read as seconds. No behaviour change, no relay
|
||
contact, no new dependency — the units the read path already compared by hand are
|
||
now the types it compares.
|
||
|
||
Gate, all green: `cargo test -p concord -p community` (51 + 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`.
|
||
|
||
### Phase 6 — the rekey writer — **landed**
|
||
|
||
§6's writer: `cord06::plan_rotation`, `rekey::{Rewrite, rotate}`, `carry_heads`,
|
||
and `Community::rotate`, which publishes a rotation and then adopts it through the
|
||
receiver phase 3 built. The receiver also keeps the signing root a refounding
|
||
hands it now, instead of dropping it.
|
||
|
||
Gate, all green: `cargo test -p concord -p community` (51 + 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`. No test was added for the writer itself:
|
||
the publish half needs a relay, and the same reason the reference's own rekey
|
||
paths are exercised by hand applies here — see `Tests` below.
|
||
|
||
## 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 landed in §6 (phase 3); honest empty states
|
||
remain open 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; and the walk reports the floor the older pass resumes from
|
||
— **landed in revision 4** (`a_walk_pages_back_across_a_rekey` asserts
|
||
`page.oldest`, the half `sync_round` resumes from). (The page-REQ half still
|
||
needs a relay.)
|
||
- A Refounding's plane — **landed in revision 4**
|
||
(`sync::tests::a_refounding_moves_a_public_channels_plane_to_the_new_root_epoch`):
|
||
with the root at epoch 1, the prior root held, and the channel's material still
|
||
naming epoch 0, the current root's channel plane is the one subscribed and read,
|
||
and a message sealed at it folds into the channel. Before the fix the plane was
|
||
derived at epoch 0 and the test failed on the missing plane.
|
||
- A delivered wrap — **landed in revision 4**
|
||
(`sync::tests::a_fold_caches_a_channel_wrap_a_relay_delivered`): a wrap saved
|
||
into the database (what the SDK does with what a subscription delivered) and
|
||
nothing else is opened and cached by the next fold, which is the step a live
|
||
message depends on. Its `unreadable` counterpart was already covered.
|
||
- The cursor's clock — **landed in revision 4**
|
||
(`a_future_stamp_cannot_push_a_cursor_past_now`): a round's future stamp is
|
||
clamped to `now` while its other findings land, and a cursor already stored past
|
||
`now` heals; `sync::live_window` starts at `now - CURSOR_OVERLAP` for such a
|
||
cursor instead of opening the REQ ahead of the present.
|
||
- The roster's authority — **landed in revision 5**
|
||
(`sync::tests::a_refounders_snapshot_seeds_the_members_it_names`): a snapshot
|
||
sealed to the current epoch's Guestbook seeds a member who has never been seen
|
||
publishing, and the same wraps seed nobody once the refounder is not in
|
||
`refounders` — the authority is the rotation, never the author alone.
|
||
- A rotation off a retained root — **landed in revision 5**
|
||
(`rekey::tests::a_rotation_past_a_retained_root_is_adopted_and_names_its_refounder`):
|
||
with the root before it retained, a rotation into epoch 2 is adopted from epoch
|
||
1 and its rotator comes back as the epoch's refounder. The unit-half of §12c's
|
||
recovery: what still needs a relay is whether the old chunk is *still there* to
|
||
be read at all.
|
||
- Retaining what was rotated past — **landed in revision 5**
|
||
(`sync::tests::a_list_material_at_a_newer_epoch_keeps_the_root_it_superseded`
|
||
and `a_list_that_moved_the_root_on_retains_the_root_of_our_join`): material at a
|
||
newer epoch replaces the current root and retires the one it moved past, and a
|
||
List whose seed names our join keeps that root held.
|
||
- The page a rotated community's history sits on — the same retention is what
|
||
`a_walk_pages_back_across_a_rekey` pages across, and revision 5 is what makes a
|
||
*List*-learned refounding leave those keys held, not only a blob-learned one.
|
||
- 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 cursor's unit — **landed in phase 5**: a merge only moves forward and never
|
||
earns `exhausted`; a document whose cursors were stored in the old millisecond
|
||
encoding reads as no cursors at all, while a typed one round-trips
|
||
(`concord/src/state.rs`).
|
||
- 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 rekey watch and adoption — **landed in phase 3** (`community/src/rekey.rs`
|
||
tests): a complete base rotation whose `prevcommit` extends the held root is
|
||
adopted with the prior root retired at the rotation's publish time; a rotation
|
||
off a key we do not hold is never adopted; a complete blob-less rotation from a
|
||
rotator who outranks us, published after we joined, reads as a removal; a
|
||
channel rotation replaces the key and keeps the prior. The pump's half
|
||
(`community/src/lib.rs`) is that a rekey watch's event wakes its community.
|
||
- The rekey writer — **landed in phase 6, structurally only.** No test covers it:
|
||
the half that could be tested without a relay (that `plan_rotation` mints two
|
||
unrelated keys, that `Rewrite::authorized` agrees with the receiver) would not
|
||
catch the failures that matter (a blob addressed to the wrong epoch, a chunk set
|
||
the receiver cannot collect, a refounding that carries no head), and the half
|
||
that would — publish, re-read, adopt — needs a real relay and a second account.
|
||
What stands in for it is that the writer cannot take a path the receiver does
|
||
not: same `permissions`, same `build_rekey_chunks`, and adoption goes through
|
||
`rekey::adopt` rather than beside it.
|
||
- 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. Revision 4 added the rotation of a quiet
|
||
community's standing REQ to the same pass, which is structural in the same way:
|
||
what it needs to be observed is a relay that drops a subscription without
|
||
saying so.
|
||
- 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; the panel's notice precedence; `due()`/`stale()`
|
||
under a driven clock; and — added by revision 5 — that a read carrying history
|
||
older than the last row lands *above* it instead of at the end. (No GPUI test
|
||
harness exists in the repo yet, so the merge is asserted by reading it.)
|
||
- 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, 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
|
||
|
||
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.
|
||
The rekey writer is the one exception, and only its mechanism: `Community::rotate`
|
||
exists to be called, but nothing in the app calls it. Kicking a member, choosing
|
||
the audience a rotation keeps, and showing any of it stay out.
|
||
`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.
|