updat
This commit is contained in:
@@ -35,9 +35,20 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
|
||||
`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.
|
||||
|
||||
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):
|
||||
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so,
|
||||
phase 5 typed the times they all compare):
|
||||
|
||||
```
|
||||
CommunityPanel::load community_ui/src/lib.rs:192
|
||||
@@ -277,10 +288,10 @@ impl Window {
|
||||
/// Nothing held: ask wide, the round pages down from there.
|
||||
/// Something held: ask only for what is new, plus an overlap for the seam.
|
||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||
match cursor.newest_ms {
|
||||
Some(newest_ms) => Window {
|
||||
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||
until_ms: None,
|
||||
match cursor.newest {
|
||||
Some(newest) => Window {
|
||||
since: Some(newest - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Window::default(),
|
||||
}
|
||||
@@ -296,13 +307,13 @@ pub fn live_window(state: &CommunityState) -> Window {
|
||||
let floor = state
|
||||
.channels
|
||||
.iter()
|
||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
|
||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
|
||||
.min();
|
||||
|
||||
match floor {
|
||||
Some(floor) => Window {
|
||||
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||
until_ms: None,
|
||||
since: Some(floor - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Window::default(),
|
||||
}
|
||||
@@ -352,7 +363,7 @@ relay.
|
||||
|
||||
Rules carried over from phase 1, none of which are negotiable:
|
||||
|
||||
- **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive
|
||||
- **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
|
||||
@@ -366,8 +377,8 @@ Rules carried over from phase 1, none of which are negotiable:
|
||||
as refused, and it is surfaced.
|
||||
- **One cursor, one filter per page.** The database is not per relay, so per-relay
|
||||
cursors do not exist; a relay is a source that fills the database.
|
||||
- **`newest_ms` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
||||
`oldest_ms` only walks down on a complete page, and `exhausted` is cleared by a
|
||||
- **`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:
|
||||
@@ -375,9 +386,9 @@ The cold/warm distinction is now the *window rule* rather than a special case:
|
||||
| State of the channel | Window |
|
||||
| -------------------- | ------ |
|
||||
| no cursor, nothing cached (cold open) | `Window::opening` → `since = None`: subscribe for everything the relays have, then page down |
|
||||
| cursor with `newest_ms` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
||||
| cursor with `oldest_ms` | `Window::older_than(oldest_ms)`: older data, on demand |
|
||||
| a hole between `saved.newest_ms` and the newest seen | `Window::between(..)`: the bridge |
|
||||
| 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
|
||||
@@ -390,9 +401,9 @@ And the passes fall out of that rule:
|
||||
- **Newest pass** (`CatchUp`): one page at `Window::opening(saved)`. Cold, this is
|
||||
"get all data"; warm, this is "get only new data".
|
||||
- **Bridge**: while the previous page was *full* and still above
|
||||
`saved.newest_ms`, keep walking down with `until = oldest - 1`, bounded by
|
||||
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest_ms` advance.
|
||||
- **Older pass**: resume at `saved.oldest_ms.or(oldest_seen)` and walk down,
|
||||
`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`,
|
||||
@@ -452,7 +463,7 @@ the next ones:
|
||||
|
||||
- `ChannelKeyRef` gains
|
||||
`#[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey>`
|
||||
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<u64> }`, and
|
||||
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
|
||||
@@ -521,9 +532,49 @@ Three deviations from the sketch above, all of them smaller than planned:
|
||||
adoptable, so a member who missed several rotations catches up in one database
|
||||
read instead of one pass per poll. The lookahead window is what feeds it.
|
||||
|
||||
Still deferred, and named here so it is not mistaken for landed: a rotation this
|
||||
client published itself is not adopted locally (no rekey writer exists yet, so the
|
||||
watch only ever adopts another member's).
|
||||
#### 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.
|
||||
|
||||
### 7. Honest states (phase 4) — **landed**
|
||||
|
||||
@@ -659,6 +710,50 @@ NIP-44-open them all. The plan's per-channel work queue in the pump turned out t
|
||||
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.
|
||||
|
||||
## Order of work
|
||||
|
||||
### Phase 2a — move the code (no behaviour change) — **landed**
|
||||
@@ -777,13 +872,42 @@ Five recorded deviations:
|
||||
|
||||
Phase 4 leaves the client honest about what it can see and what it can write.
|
||||
What it does not do is make it see more: the rekey writer, and with it adopting a
|
||||
rotation this client published itself, remains open (see §6).
|
||||
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:
|
||||
@@ -832,6 +956,10 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
`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
|
||||
@@ -845,8 +973,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
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 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
|
||||
@@ -885,6 +1020,9 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user