This commit is contained in:
2026-09-22 15:30:12 +07:00
parent 04fb70e657
commit 28f4c1596d
9 changed files with 1431 additions and 80 deletions
+95 -18
View File
@@ -36,7 +36,8 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
wait, and it is reported rather than rendered as an empty channel.
Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b
swapped the transport and put the pump in charge of settling pages):
swapped the transport and put the pump in charge of settling pages, phase 3 added
the rekey watch and held epochs):
```
CommunityPanel::load community_ui/src/lib.rs:192
@@ -60,10 +61,17 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
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)
```
## What is wrong today
@@ -76,7 +84,7 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
| 4 | Cursors existed but nothing used them; no "has more" signal. | fixed in phase 1 | `community/src/community.rs:337` |
| 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | fixed in phase 2b (`planes` derives a private channel's plane from the held key; it is subscribed, paged and folded like a public one) | `community/src/sync.rs` |
| 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | fixed in phase 2b (`live_filter`/`plane_filter` ask for both kinds; the pump routes by subscription id and never inspects the kind) | `community/src/sync.rs`, `community/src/lib.rs` |
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | open (phase 3) | `community/src/sync.rs:331-368`, `community/src/community.rs:199-216` |
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | fixed in phase 3 (`priors`/`held_roots` + `retired_at`, the rekey watch, and strict one-epoch-at-a-time adoption) | `community/src/rekey.rs`, `concord/src/state.rs` |
| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` |
| 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` |
@@ -177,7 +185,7 @@ Notes:
| Community List | `concord/list` (existing) | registry | signer lifetime |
| Live planes | `<community hex>` (existing `sync::subscription_id`) | community | until the signer or the plane set changes; **kept alive** |
| History page | `concord-history-<n>` (opaque, unique) | the round | one page; auto-closes on EOSE |
| Rekey watch (phase 3) | `concord-rekey/<community hex>/<n>` | community | kept alive while the community is tracked |
| 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
@@ -192,7 +200,10 @@ instead — `PageRegistry`, a `HashMap<SubscriptionId, flume::Sender<PageReport>
shared between the registry and the rounds. `PageReport { relay, outcome }`
carries facts; the walk decides what they mean. A report for an id nobody is
waiting on is dropped at `debug`, which is the ordinary case when a round is
cancelled. Phase 3 can add a `Rekey` route the same way, by id.
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
@@ -412,8 +423,9 @@ pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize
### 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
- **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).
@@ -427,7 +439,7 @@ pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize
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)
### 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:
@@ -465,6 +477,49 @@ the next ones:
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.
Still deferred, and named here so it is not mistaken for landed: the panel does
not yet render `Community::removed_at()` / `Community::stranded()` (phase 4), and
a base removal is not enforced at send time — `channel_secret` still hands the
composer the retired root.
### 7. Honest states (phase 4)
- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`;
@@ -578,23 +633,36 @@ not account for:
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
### Phase 3 — epochs and rekeys — **landed**
§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.
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
§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
scheduler, the `removed`/`stranded` rendering phase 3 persisted but left
unpainted, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
supports negentropy; "negentropy unsupported" means "fall back to the paged
walk", never "exhausted").
Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
2b is what makes "open a community" a subscription and a database read. Phase 3
(§6) is next: it is what keeps a rekey from stranding history.
2b is what makes "open a community" a subscription and a database read; phase 3
is what keeps a rekey from stranding history. Phase 4 (§7) is next: it is what
makes an empty or unreadable room tell the truth.
## Phase 1 status (landed)
@@ -617,10 +685,10 @@ What phase 1 delivered, and what phase 2 replaces:
... the next round retries"). The SDK re-issues the REQ under the same id after
AUTH — for auto-closing subscriptions too — so the honest statement is: the
relay stays in the walk and the page waits for its resubscribed answer.
- Held epoch handling and rekeys are still open in §6, and honest empty states in
§7. Private planes, the second wrap kind, the expired-row sweep, the stable
cache key and the fold cost were open before phase 1 and are now landed (§5,
§9).
- 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
@@ -650,6 +718,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
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 page registry — **landed in 2b**: a page that has already unregistered
receives nothing.
- The read path: the side-event budget folds an edit/delete/reaction older than
the row window onto its message.
- The fold: a new live wrap costs one decrypt, and a fold over a community with