update
This commit is contained in:
+42
-10
@@ -224,6 +224,11 @@ Relay history pages through the local cache:
|
||||
```rust
|
||||
use community::history::{self, PageRegistry, Window};
|
||||
|
||||
// Every key the client still holds for the channel, newest epoch first: the
|
||||
// state's current key (`ChannelKeyRef::key` at `epoch`) plus every `priors`
|
||||
// entry a rotation stepped off. A public channel derives one per held root.
|
||||
let held: Vec<concord::state::HeldKey> = state.held_keys(&channel);
|
||||
|
||||
// The same registry `CommunityRegistry` shares with its notification pump: it is
|
||||
// what carries each page's EOSE and CLOSED back to the round waiting on it.
|
||||
let page = history::page(
|
||||
@@ -240,6 +245,10 @@ let page = history::page(
|
||||
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
|
||||
```
|
||||
|
||||
A key's `retired_at` (epoch seconds, set when a rotation supersedes it) is a read
|
||||
cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired
|
||||
epoch is history and never a live plane an ejected holder can keep writing into.
|
||||
|
||||
A relay is only ever *asked*: `history::page` installs **one REQ per page** over
|
||||
`ReqTarget::manual` for the community's own relays (which is what makes the client
|
||||
verify a wrap, deduplicate it and persist it), waits for every relay to settle,
|
||||
@@ -446,6 +455,12 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k
|
||||
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
|
||||
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
|
||||
|
||||
`community::rekey::adopt` is that receiver, run against the local database: it
|
||||
walks a scope forward one epoch at a time off the key actually held (never
|
||||
waiving a gap, never adopting a fork), keeps each stepped-off key as a prior
|
||||
with the rotation's publish time as its read cutoff, and reports a removal or a
|
||||
strand when a complete rotation carries no blob for the member.
|
||||
|
||||
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
|
||||
text-only, so `build_blob` carries it base64-encoded inside the envelope.
|
||||
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
|
||||
@@ -635,10 +650,24 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
rekey fold changes it.
|
||||
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
|
||||
kind.
|
||||
- Watch one epoch ahead: while holding `root_N`, subscribe to
|
||||
`base_rekey_group_key(&root_N, &community_id, Epoch(N + 1))` and to
|
||||
`channel_rekey_group_key(&root_N, &channel, Epoch(N + 1))` for each private
|
||||
channel. A second epoch ahead is not derivable until the new root arrives.
|
||||
- Watch one epoch ahead for the base, and a **window** of channel epochs under
|
||||
every held root for private channels: `base_rekey_group_key(&root_N, &id,
|
||||
Epoch(N + 1))`, plus `channel_rekey_group_key(&root, &channel, Epoch(channel_epoch
|
||||
+ ahead))` for `ahead in 1..=8` and each root in `state.roots()`. A Refounding
|
||||
seals its channel rekeys under the root current when it was minted, so a member
|
||||
who adopted the base rotation first must still ask under the prior root; the
|
||||
window is what lets a member who missed a rotation catch up, or learn they were
|
||||
cut. This is `community::rekey::watches`, installed as a second standing REQ
|
||||
whose id resolves back to its community through `rekey::WatchRegistry` (the id
|
||||
cannot carry a 64-hex community id within the NIP-01 length cap).
|
||||
- `community::rekey::adopt` then reads those wraps **from the database** and walks
|
||||
each scope forward one epoch at a time: a complete, authorized rotation whose
|
||||
`prevcommit` extends the key actually held hands over the next key (raced
|
||||
rotations settle on the lowest); a gap is fetched, never waived; a fork is never
|
||||
adopted. An addressed-but-unverifiable rotation is neither adopted nor read as a
|
||||
removal. Only a complete rotation at/after the join, from a rotator who outranks
|
||||
the member, with no blob for them, is a removal; one that predates the join is a
|
||||
strand — a stale invite landed the member on a superseded epoch.
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -683,9 +712,12 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
||||
that handler must route by subscription id before any concord subscription goes
|
||||
live, or every stream wrap lands in the DM trash and raises a toast.
|
||||
- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
|
||||
nowhere to keep a key a rotation delivered, so a client can verify a rotation
|
||||
and still lose it on restart — history under a prior root or a prior channel
|
||||
epoch is unreadable until that schema change lands. (A granted private-channel
|
||||
key does now have a home: `ChannelKeyRef.key`, filled by
|
||||
`CommunityState::from_join_material`.)
|
||||
- **Rotation-delivered plane keys are persisted, and history spans them.**
|
||||
`CommunityState.held_roots` keeps every root a rotation stepped off (with the
|
||||
retired root's Control signer and the publish time that retires it), and each
|
||||
`ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read
|
||||
side derives planes from all of them, and a retired key's `retired_at` is a hard
|
||||
read cutoff at both `history::page` and `sync::fold`. The residual gaps: the
|
||||
panel does not yet render the `removed`/`stranded` state `Community::removed_at`
|
||||
and `Community::stranded` carry (phase 4), and a base removal is not yet enforced
|
||||
at send time — the composer still has the old root to write under.
|
||||
|
||||
Reference in New Issue
Block a user