add control fold and roster

This commit is contained in:
2026-09-16 20:05:51 +07:00
parent d926c1e3ea
commit 4329385abe
8 changed files with 2016 additions and 111 deletions
+133 -28
View File
@@ -80,8 +80,9 @@ crates/concord/
src/lib.rs init, ConcordRegistry, ConcordEvent, signal bus, subscriptions, ingest pipeline
src/derive.rs frozen HKDF / group_key / locators / commitments + golden vectors
src/stream.rs CORD-01: seal + wrap + open (SealForm, OpenedStream), channel/epoch binding
src/edition.rs CORD-04 §1: canonical signing bytes, edition hash, parse, fold
src/control.rs control plane view, genesis, content types, roster fold, authority checks
src/edition.rs CORD-04 §1: edition hash, parse, chain fold, floor-aware head selection
src/control.rs control plane: genesis, content types, the control fold, the edition writer
src/roles.rs CORD-04 §2–§4: permissions, roles, grants, banlist, delegation fixpoint
src/guestbook.rs CORD-02 §5: join/leave/kick/snapshot, coalesce, complete memberlist
src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view
src/invite.rs CORD-05: bundle, link, registry, Invite List, Direct Invite
@@ -89,11 +90,11 @@ crates/concord/
src/store.rs local persistence + opened-rumor cache + history queries
```
`Community` and `Channel` GPUI entities live in `src/lib.rs` next to the registry — they are the public surface, not a separate concern. Ten modules, each with real content; no single-fn files.
`Community` and `Channel` GPUI entities live in `src/lib.rs` next to the registry — they are the public surface, not a separate concern. Eleven modules, each with real content; no single-fn files.
Dependencies: `common`, `state`, `person`, `device`, `settings`, `gpui`, `nostr` (for `nip44` features), `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow`, `flume`, `smallvec`, `itertools`, `futures`, `log`, `instant`. Everything under `cfg(not(target_arch = "wasm32"))` follows the `chat`/`state` split so the crate still builds for web.
Declare only what a milestone actually uses. As of M2 the crate depends on `nostr`, `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow` (plus `nostr-memory` and `smol` for tests). `rand` is pinned to the `0.10.2` instance `nostr` already builds and shares its `getrandom`, which the `web` crate already enables `wasm_js` on — so no new package and no new wasm obligation. `serde`/`serde_json` were already in the graph via `nostr`; promoting `serde_json` from dev to main for the metadata content types added no package either, only the `concord → serde` edge.
Declare only what a milestone actually uses. As of M3 the crate depends on `nostr`, `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow` (plus `nostr-memory` and `smol` for tests). `rand` is pinned to the `0.10.2` instance `nostr` already builds and shares its `getrandom`, which the `web` crate already enables `wasm_js` on — so no new package and no new wasm obligation. `serde`/`serde_json` were already in the graph via `nostr`; promoting `serde_json` from dev to main for the metadata content types added no package either, only the `concord → serde` edge. M3 added no dependency of its own.
## 5. Core types
@@ -249,7 +250,7 @@ Design points that are easy to get wrong:
## 8. Planes, state and folds
### 8.1 Editions and authority (`edition.rs`, `control.rs`)
### 8.1 Editions, authority and the control fold (`edition.rs`, `roles.rs`)
```rust
pub const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; // frozen, cross-client (27 bytes)
@@ -267,6 +268,13 @@ pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionErro
pub struct FoldResult { pub head: Option<usize>, pub gap: bool, pub anchored: bool }
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult;
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize>; // highest, contiguity ignored
// One entity's committed head, and the refuse-downgrade floor a later fold is judged
// against.
pub struct EntityHead { entity: [u8; 32], version: u64, self_hash: [u8; 32], rumor_id: EventId }
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
pub struct HeadSelection { pub head: Option<usize>, pub gap: bool }
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection;
```
- Tag grammar: `["vsk", sub]`, `["eid", hex32]`, `["ev", decimal]`, `["ep", hex32]`, `["vac", eid, version, hash]`. Duplicates of any of the five reject the edition; `ev` must pass a decimal check before parsing. A version of `0` parses and then reads as a gap — the rule lives in the fold, not the parser.
@@ -274,42 +282,121 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize>; // highest,
- **The edition hash is not the signature.** The actor's Schnorr signature covers the kind-20014 plaintext seal; `edition_hash` is a separate SHA-256 used only for chaining (`ep`, `vac`). `content` is the rumor's content string byte-verbatim, never re-serialized, which is what lets compaction re-wrap a head and preserve its hash.
- The domain label is `vector-community/v1/edition`, not a `concord/…` label. Inconsistent with Appendix A.6, frozen anyway — do not "fix" it.
- Tie-break at equal version is the lower **inner rumor id** (the kind-3308 rumor), never the outer wrap id and never `created_at`. Only one of the two implementations that must agree applies to a wrap, so the inner id is the only stable choice.
- `gap` is a safety signal: a tracking client (already holds the floor) fails closed for that entity and refetches; a bootstrapping client (floor 0) may accept the highest authority-verified head, which is what makes compaction re-wrapping work. `bootstrap_head` therefore takes no floor: it *is* the floor-zero path.
- `gap` is a safety signal: a tracking client (already holds the floor) fails closed for that entity and refetches; a bootstrapping client (floor 0) may accept the highest authority-verified head, which is what makes compaction re-wrapping work. `bootstrap_head` therefore takes no floor: it *is* the floor-zero path. `fold_head` is the composition — floor 0 takes `bootstrap_head`; under a held floor the chain-anchored head wins and any upper gap is reported; everything below the floor is a stale relay, not a gap; and a head detached from the floor converges a same-version fork to its lower rumor id when that is genuinely earlier than what we hold, else fails closed as withholding.
- **Owner anchoring is not in the fold.** `fold` is a pure function of chain shape; authority is a pre-filter the caller applies to the candidate set before folding. `community_id` proves the owner, and `is_authorized` short-circuits `owner == actor`, so the owner needs no Grant entity at all.
- Entity coordinates are `vsk 0``community_id`, `1``role_id`, `2``channel_id`, `3``grant_locator`, `4``banlist_locator`, `8``invite_links_locator`, `11``pins_locator`. `5` is reserved, `6`/`9` belong to the 33301 invite marker, `7` is retired. All derive from `community_id` only, so a refounding re-wraps heads verbatim.
- The Control Plane is **plaintext-seal only**. A 20013-encrypted control edition is rejected, because compaction re-wraps a signed plaintext seal byte-verbatim into the new epoch; accepting an encrypted one would let a later compaction fork the chain.
- **Genesis is exactly two owner-signed editions** — community metadata (`vsk 0`, `eid = community_id`) and one public `#general` channel (`vsk 2`, fresh random `channel_id`) — at epoch 0, version 1, no `ep`, no `vac`. No default roles, no scaffolding, and no Grant for the owner. Secrets minted: `owner_salt`, `community_root`, `control_root` (deliberately not derived from `community_id`).
```rust
pub const P_MANAGE_ROLES: u64 = 1 << 0; // …bit table from CORD-04 §3, frozen; retired bits are burned
pub struct CommunityRoles { roles: BTreeMap<[u8; 32], Role>, grants: BTreeMap<PublicKey, Grant> }
impl CommunityRoles {
pub fn permissions_of(&self, member: &PublicKey) -> u64; // union of role bits
pub fn position_of(&self, member: &PublicKey, owner: &PublicKey) -> u32;
pub fn is_authorized(&self, actor: &PublicKey, owner: &PublicKey, bit: u64) -> bool;
pub fn is_authorized_in(&self, actor: &PublicKey, owner: &PublicKey, channel: &ChannelId, bit: u64) -> bool;
pub fn outranks(&self, actor: &PublicKey, owner: &PublicKey, target_position: u32) -> bool;
pub fn can_act_on(&self, actor: &PublicKey, owner: &PublicKey, target: &PublicKey, bit: u64) -> bool;
pub fn is_staff(&self, member: &PublicKey, owner: &PublicKey) -> bool; // the six control bits, CORD-04 §3
// CORD-04 §3, frozen. 1<<7 was MANAGE_INVITES and is burned, never reassigned.
// MANAGE_ROLES 1<<0 · MANAGE_CHANNELS 1<<1 · MANAGE_METADATA 1<<2 · KICK 1<<3 ·
// BAN 1<<4 · MANAGE_MESSAGES 1<<5 · CREATE_INVITE 1<<6 · VIEW_AUDIT_LOG 1<<8 ·
// MENTION_EVERYONE 1<<9 · PIN_MESSAGES 1<<11 · reserved: MANAGE_EMOJI 1<<10, MANAGE_EVENTS 1<<12
pub struct Permissions(pub u64);
impl Permissions {
pub const STAFF_MASK: u64; // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|BAN|CREATE_INVITE|PIN_MESSAGES
pub fn contains(self, bits: u64) -> bool;
pub fn union(self, other: Self) -> Self;
pub fn is_staff(self) -> bool;
}
pub enum RoleScope { Server, Channel(ChannelId) } // {"kind":"server"} / {"kind":"channel","channel_id":…}
pub struct Role { role_id: RoleId, name: String, position: u32, permissions: Permissions,
scope: RoleScope, color: u32, extra: Extra }
pub struct Grant { member: PublicKey, role_ids: Vec<RoleId>, control_wrap: Option<String>, extra: Extra }
pub struct CommunityRoles { roles: BTreeMap<RoleId, Role>, grants: BTreeMap<PublicKey, Grant> }
impl CommunityRoles {
pub fn role(&self, role_id: &RoleId) -> Option<&Role>;
pub fn roles_of(&self, member: &PublicKey) -> impl Iterator<Item = &Role>;
pub fn effective_permissions(&self, member: &PublicKey) -> Permissions; // union of granted role bits
pub fn has_permission(&self, member: &PublicKey, bits: u64) -> bool;
pub fn highest_position(&self, member: &PublicKey) -> Option<u32>; // lowest position they hold
pub fn is_authorized(&self, actor, owner, permission: u64) -> bool; // owner == actor → true
pub fn outranks(&self, actor, owner, target_position: u32) -> bool; // strict `<`
pub fn can_act_on_position(&self, actor, owner, target_position: u32, permission: u64) -> bool;
pub fn can_act_on_member(&self, actor, owner, target: &PublicKey, permission: u64) -> bool;
pub fn is_staff(&self, member, owner) -> bool;
}
// The delegation fixpoint. Content is parsed once, up front: the fixpoint revisits
// every candidate on each pass.
pub enum AuthorityContent { Role(Role), Grant(Grant), Banlist(Vec<PublicKey>) }
pub struct AuthorityEdition { entity: [u8; 32], meta: EditionMeta, author: PublicKey,
citation: Option<AuthorityCitation>, content: AuthorityContent }
impl AuthorityEdition {
pub fn parse(edition: &ParsedEdition, community_id: &CommunityId) -> Option<Self>;
}
pub struct Roster { roles: CommunityRoles, banned: BTreeSet<PublicKey>, floors: Floors, gapped: bool }
pub fn fold_roster(owner, community_id, editions: &[AuthorityEdition], floors: &Floors,
held_bans: &BTreeSet<PublicKey>) -> Roster;
pub fn citation_ok(owner, community_id, author, citation: Option<&AuthorityCitation>,
floors: &Floors) -> bool;
```
Authority rules to encode once and test hard:
Authority rules as implemented:
- The owner is position 0, derived from `community_id`, and is never removable.
- No edition may claim a `position` at or above its own signer's, including the owner: no Role may claim 0.
- The owner is position 0, proven by `community_id`, supreme, unremovable, and **not a Role**: no Role may claim position 0, and every gate short-circuits `owner == actor`. The owner therefore needs no Grant and cites nothing.
- A member's rank is the **lowest** position among their Roles; a roleless member sits at `u32::MAX`. Two Roles may share a position (peers, neither acts on the other); display tie-breaks on the lower `role_id`.
- The actor must hold the required bit **and strictly outrank** the target. Equal cannot act on equal.
- A `vac` citation is a sync floor, not a verdict: block until the cited Grant version is folded, verify its hash, then judge against the *current* roster.
- A staff-making Grant carries `control_wrap`, a NIP-44 pairwise ciphertext of `epoch_be[8] ‖ control_root[32]`, and is adopted **only if it derives to the `control_pk` the member already holds** for the named epoch.
- Banlist is one replaced entity; mutations carry a re-heal step (re-fold after publish, re-apply if the addition lost the tiebreak).
- `AuthorityEdition::parse` drops, rather than repairs: a `role_id` that is not its own coordinate, a `position` of 0, a Grant whose `member` does not hash to its entity, a `vsk 4` at a coordinate that is not this community's banlist locator, malformed JSON, and any `vsk` this type does not own. A Grant's `role_ids` truncate at 64 on read.
- **Refuse-downgrade**: an edition below the persisted floor for its entity is never a candidate.
- The fold is a **Jacobi fixed point** — authority propagates one delegation level per pass, bounded by `2 × (entities) + 8`. Convergence compares the roster only, not the heads. Cross-pass state is exactly the accepted roster plus its heads, and `citation_ok` reads the *previous* pass's heads, so the first pass sees none.
- **Roles** replay each entity's versions **ascending**, one winner per version group, `admissible` collecting the winners that pass. Gates, in order: not banned; `can_act_on_position(author, owner, position, MANAGE_ROLES)`; if a predecessor was admitted, the same call against *its* position; then the citation. The highest admissible version wins. Replaying ascending is what makes the second gate work: without it an admin at position 5 republishes a position-1 role at position 9, every check passes since 9 is beneath them, and a role that outranked them ends up beneath them along with everyone holding it.
- **Grants** take no version-group replay: the first candidate in vector order clearing every gate wins. Role references resolve **partially** — the resolvable subset is carried and the rest fold in on a later pass — because all-or-nothing resolution deadlocks the ordinary growth path (an admin creates a role, the owner grants it to them, and neither can go first, collapsing the entire roster including the owner's own grants). The final gate ranks every resolved position *and* the member.
- A **citation** that cannot be resolved parks the edition; a missing one is tolerated only where the rank gates carry the weight. For a Role that is everywhere. For a Grant it is not: a revoke names no position, so its rank test is vacuous — hence an uncited Grant may add authority but **never remove** it.
- The **banlist** is folded after a preliminary roster, since a ban only exists once someone authorized to place it does. Its head is the highest edition whose author currently holds `BAN` and does not already sit in the held banlist; each entry is kept only if that author strictly outranks the target, and the list caps at 500. **Withholding retains the held list** rather than un-banning nobody on a relay's word. The final roster is then re-folded with the banned set excluded, so a banned admin loses their authority in the same pass.
- A staff-making Grant carries `control_wrap`, a NIP-44 pairwise ciphertext of `epoch_be[8] ‖ control_root[32]`, adopted **only if it derives to the `control_pk` the member already holds** for the named epoch. Delivery, never authority.
- Caps: **100 Roles per community**, by the 100 lowest `role_id`, applied *after* authorization so forged low ids cannot evict a real role; the grants then shed the dropped ids. **64 Roles per member**, at parse. **500 banlist entries**, at fold.
**Two deliberate divergences from the reference implementation** (Vector is an oracle, not a specification):
1. **The role fork winner.** Vector's role branch walks version groups with `.iter().rev()`, taking each group's *highest* inner id, while its own adjacent comment says forks break on the lowest and the rest of its codebase (`fold_head`, `version::fold`, its invite-registry test) does use the lowest. No test in Vector pins the branch. We implement **lowest inner id**, per CORD-04 §1 — and our tests pin it.
2. **Banlist candidates must be `vsk 4`.** Vector collects banlist candidates from every edition sitting at the banlist locator regardless of `vsk`, so a `vsk 1` forged there can win the banlist head, parse to an empty list, and clear the ban. We require `vsk::BANLIST` for a candidate at all.
One reference limitation we **reproduce and do not fix** (recorded here rather than silently diverging): the grant rank gate reads the *previous* pass's roster, so a mid-rank `MANAGE_ROLES` holder who cites a real, folded grant of their own can revoke a higher-ranked member whose authority is still propagating. Fixing it means resolving a Grant's target rank against the same pass, which changes the fixpoint's convergence argument. Revisit only with a spec amendment.
### 8.2 Communities, channels, metadata
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), `message_expiration`, and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`.
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`. Every content struct carries `#[serde(flatten)] extra`, so a field one client does not model still round-trips and a rename by an older client cannot wipe another client's `custom` keys.
Every content struct uses `#[serde(flatten)] extra: serde_json::Map<String, Value>` and round-trips unknown fields. A name edit by an older client must not wipe another client's `custom` keys. Round-trip discipline gets its own test.
The Control Plane's whole projection is one call:
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners.
```rust
pub struct ControlFold {
pub roles: CommunityRoles,
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
pub floors: Floors,
pub gapped: bool,
}
pub fn fold_control(owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition],
floors: &Floors, held_bans: &BTreeSet<PublicKey>) -> ControlFold;
```
- The roster is folded first, and `vsk 0` / `vsk 2` are then judged against it: the head of each entity is the highest edition whose author *currently* holds `MANAGE_METADATA` / `MANAGE_CHANNELS`, is not banned, and either is the owner or cites their own folded Grant. Pre-filtering before the chain fold is what stops a demoted admin's later, higher-version edition from being the head.
- A `vsk 2` whose entity is the community's own id is excluded, and a `vsk 0` at any other coordinate with it: the floor row keys on the entity alone, so the two would otherwise share and corrupt one chain.
- `None` means "this client saw no authorized edition", never "the value is gone": a caller keeps what it holds rather than walking the community backwards. That is also how a withheld or downgraded entity reads.
- A `deleted` channel is reported as metadata with `deleted: true`; the policy of dropping it belongs to the store.
Writes go through one primitive, so every edition names the head it supersedes and a client cannot silently fork a chain it cannot see:
```rust
pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str,
head: Option<&'a EntityHead>, citation: Option<AuthorityCitation> }
pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey }
impl ControlWriter {
pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>;
pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
}
```
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`.
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key.
### 8.3 Guestbook and member list (`guestbook.rs`)
@@ -413,7 +500,7 @@ pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // re-wrap h
Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored.
## 9. Storage (`store.rs`) — local layer implemented in M1
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3
Three layers, no new storage engine:
@@ -453,6 +540,17 @@ pub struct CommunityState {
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key.
M3 added the two bridges between this document and the fold:
```rust
impl CommunityState {
pub fn floors(&self) -> Floors; // the fold's input
pub fn apply_fold(&mut self, fold: &ControlFold); // the fold's output
}
```
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it.
Writes are debounced (a fold head changes on every edition); reads load once at init.
**Decision, stated for the record:** this document stores raw community keys unencrypted in a plaintext local database. That matches the existing posture — `chat` already caches decrypted message rumors in the same LMDB. If that posture ever changes, the state document is the one to wrap with NIP-44-to-self, since it is the only local artifact holding keys.
@@ -577,7 +675,7 @@ pub fn pin(&self, id: EventId, cx: &App) -> Task<Result<(), Error>>; // vsk 11,
## 11. Integration with existing crates
1. **`crates/chat/src/lib.rs` — required fix, moved from M2 to the milestone that first subscribes.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-59 gift wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` recipient heuristic. **M2 did not apply it**: the crate has no subscription and no `ConcordRegistry` yet, so no concord wrap can reach that handler and the change would be untestable. It lands with the sync engine (§10), as does the `concord::init` wiring in `desktop` and `web`.
1. **`crates/chat/src/lib.rs` — required fix, moved from M2 to the milestone that first subscribes.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-59 gift wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` recipient heuristic. **M2 and M3 did not apply it**: the crate has no subscription and no `ConcordRegistry` yet, so no concord wrap can reach that handler and the change would be untestable. It lands with the sync engine (§10), as does the `concord::init` wiring in `desktop` and `web`.
2. **`desktop/src/main.rs` and `web/src/lib.rs`** — add `concord::init(window, cx)` after `chat::init(window, cx)`.
3. **`Cargo.toml`** — add `hkdf = "0.12"` to `[workspace.dependencies]`; add the crate to `desktop` and `web` dependencies. No other workspace changes.
4. **No changes** to `state`, `person`, `device`, `settings`, `common`, or `ui`.
@@ -599,6 +697,7 @@ Each of these has burned a real implementation, or is a documented cross-client
- Refuse to write a Pin List from a list the writer could not read.
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points.
- Lowercase hex only; x-only pubkeys only; no version tag anywhere.
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate. Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts.
## 13. Milestones
@@ -607,7 +706,7 @@ Each of these has burned a real implementation, or is a documented cross-client
| M0 | Crate skeleton, `derive.rs`, golden vectors, workspace wiring | ✅ `cargo test -p concord` pins every derivation; all labels match Appendix A.6 |
| M1 | `stream.rs` + `store.rs` | ✅ seal/wrap/open round-trips for both seal forms; hostile wraps rejected in the documented order; the local cache reads back with the group key gone |
| M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 |
| M3 | Control fold + roster + metadata/channels | fold tests for chains, gaps, downgrade refusal, fork tiebreak, compaction dangle; metadata and channel edits visible to a second client |
| M3 | Control fold + roster + metadata/channels | `cargo test -p concord` (15 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
| M4 | Chat plane | send/receive/edit/delete/reaction across two identities; history pages backwards across an epoch boundary (relay `backfill` lands here); binding checks reject a foreign channel/epoch |
| M5 | Guestbook + member list + moderation | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test |
| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 |
@@ -620,6 +719,10 @@ M1 closed with `cargo test -p concord` (5 tests), `cargo clippy -p concord --all
M2 closed the same way at 7 tests, with `serde` added to the crate's dependencies (`serde_json` promoted from dev to main for the metadata content types) — `Cargo.lock` gained no package again, only the `concord → serde` edge.
M3 closed at 15 tests with no dependency change at all, and `Cargo.lock` untouched. New: `src/roles.rs` (permissions, Role/Grant/banlist content, `CommunityRoles`, the delegation fixpoint) and, in `src/control.rs`, `ControlFold` / `fold_control`, the metadata-and-channel fold, `ControlWriter` and its `Edition` input. `EntityHead` and `Floors` moved from `store.rs` into `edition.rs`, where `fold_head` now composes `fold` and `bootstrap_head` for the floor-aware case.
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`.
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
## 14. Open questions and risks
@@ -632,6 +735,8 @@ M2 closed the same way at 7 tests, with `serde` added to the crate's dependencie
6. **Local plaintext state.** §9 records the decision. Revisit only if the local database stops being treated as trusted.
7. **Was a `community_id` ever hashed into a tag?** No — it must never appear on the wire. A lint-style test should assert it appears in no published event other than inside an invite bundle and a direct invite.
8. **The Pin List's message-key disclosure has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync).
9. **A remote signer is not plumbed.** `ControlWriter::publish` and `stream`'s seal builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4M8 depends on it except the UX of using a remote signer at all.
10. **The fold is not incremental.** `fold_control` re-parses and re-folds the whole control edition window on every call, and each fold is up to `2 × entities + 8` passes. That is fine at the caps the spec sets (100 roles, 400-odd grants) and it is the simplest thing that is correct, but if the sync engine ends up calling it per event rather than per batch, the candidate maps and their parse belong in a cache keyed by edition id. Measure before optimizing.
## 15. Test strategy
+572 -58
View File
@@ -1,14 +1,21 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::derive::{
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
};
use crate::edition::{EditionFields, ParsedEdition, build_edition, parse_edition, vsk};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::roles::{
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster,
};
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64;
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
@@ -17,12 +24,8 @@ pub const MAX_RELAYS: usize = 5;
pub const GENERAL_CHANNEL: &str = "general";
pub const ROOT_EPOCH: Epoch = Epoch(0);
/// The first edition every entity starts at. Genuinely 1, not 0: a version of
/// 0 is what the fold treats as a gap.
const GENESIS_VERSION: u64 = 1;
type Extra = Map<String, Value>;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ImageRef {
pub url: String,
@@ -64,7 +67,6 @@ pub struct ChannelMetadata {
pub extra: Extra,
}
/// A community's permanent identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunityIdentity {
pub community_id: CommunityId,
@@ -78,12 +80,6 @@ impl CommunityIdentity {
}
}
/// Everything a creation mints:
///
/// - Identity
/// - Roots an invite will carry
/// - First channel
/// - Two genesis wraps to publish
#[derive(Debug, Clone)]
pub struct CommunityGenesis {
pub identity: CommunityIdentity,
@@ -93,29 +89,14 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>,
}
/// Mints a community and signs its two genesis editions.
pub fn genesis(
owner: &Keys,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
let mut metadata = metadata.clone();
if metadata.name.len() > MAX_NAME_BYTES {
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
}
if metadata
.description
.as_ref()
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
{
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
}
metadata.relays.truncate(MAX_RELAYS);
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
@@ -129,7 +110,6 @@ pub fn genesis(
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
let metadata_content = serde_json::to_string(&metadata)?;
let channel_content = serde_json::to_string(&ChannelMetadata {
name: GENERAL_CHANNEL.to_owned(),
private: false,
@@ -190,6 +170,277 @@ pub fn open_edition(
Ok(parse_edition(&opened.rumor)?)
}
/// Appends editions to entity chains.
pub struct ControlWriter {
pub author: PublicKey,
pub read: GroupKey,
pub signer: GroupKey,
}
pub struct Edition<'a> {
pub subkind: &'a str,
pub entity: [u8; 32],
pub content: &'a str,
/// The head this edition supersedes.
///
/// `None` starts the chain.
pub head: Option<&'a EntityHead>,
pub citation: Option<AuthorityCitation>,
}
impl ControlWriter {
pub fn publish(
&self,
keys: &Keys,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
entity: edition.entity,
version: edition
.head
.map_or(GENESIS_VERSION, |head| head.version + 1),
prev: edition.head.map(|head| head.self_hash),
citation: edition.citation,
content: edition.content,
at_secs,
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
&self,
keys: &Keys,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = encode_metadata(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head,
citation: None,
},
at_secs,
)
}
pub fn set_channel_metadata(
&self,
keys: &Keys,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = serde_json::to_string(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::CHANNEL_METADATA,
entity: *channel.as_bytes(),
content: &content,
head,
citation: None,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
if metadata.name.len() > MAX_NAME_BYTES {
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
}
if metadata
.description
.as_ref()
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
{
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
}
let mut metadata = metadata.clone();
metadata.relays.truncate(MAX_RELAYS);
Ok(serde_json::to_string(&metadata)?)
}
#[derive(Debug, Clone, Default)]
pub struct ControlFold {
pub roles: CommunityRoles,
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
pub floors: Floors,
pub gapped: bool,
}
pub fn fold_control(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
floors: &Floors,
held_bans: &BTreeSet<PublicKey>,
) -> ControlFold {
let authority: Vec<AuthorityEdition> = editions
.iter()
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
.collect();
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
let mut floors = roster.floors;
floors.extend(metadata.floors);
ControlFold {
roles: roster.roles,
banned: roster.banned,
community: metadata.community,
channels: metadata.channels,
floors,
gapped: roster.gapped || metadata.gapped,
}
}
#[derive(Debug, Default)]
struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
floors: Floors,
gapped: bool,
}
fn fold_metadata(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
roster: &Roster,
floors: &Floors,
) -> MetadataFold {
let judge = Judge {
owner,
community_id,
roster,
floors,
};
let community_entity = *community_id.as_bytes();
let mut community: Vec<&ParsedEdition> = Vec::new();
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
match edition.subkind.as_str() {
// A channel addressed at the community's own coordinate would share, and
// corrupt, the metadata chain's floor.
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
community.push(edition)
}
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
channels.entry(edition.entity).or_default().push(edition);
}
_ => {}
}
}
let mut fold = MetadataFold::default();
if let Some(head) = authorized_head(
&judge,
community_entity,
&community,
Permissions::MANAGE_METADATA,
&mut fold.gapped,
) {
fold.community = serde_json::from_str(&head.content).ok();
fold.floors.insert(head.entity, EntityHead::from(head));
}
for (entity, candidates) in &channels {
let Some(head) = authorized_head(
&judge,
*entity,
candidates,
Permissions::MANAGE_CHANNELS,
&mut fold.gapped,
) else {
continue;
};
fold.floors.insert(*entity, EntityHead::from(head));
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
fold.channels
.insert(ChannelId::from_bytes(*entity), metadata);
}
}
fold
}
struct Judge<'a> {
owner: &'a PublicKey,
community_id: &'a CommunityId,
roster: &'a Roster,
floors: &'a Floors,
}
fn authorized_head<'a>(
judge: &Judge<'_>,
entity: [u8; 32],
candidates: &[&'a ParsedEdition],
permission: u64,
gapped: &mut bool,
) -> Option<&'a ParsedEdition> {
let authorized: Vec<&ParsedEdition> = candidates
.iter()
.copied()
.filter(|edition| {
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
!judge.roster.banned.contains(&edition.author)
&& judge
.roster
.roles
.is_authorized(&edition.author, judge.owner, permission)
&& citation_ok(
judge.owner,
judge.community_id,
&edition.author,
edition.citation.as_ref(),
&judge.roster.floors,
)
})
.collect();
if authorized.is_empty() {
return None;
}
let metas: Vec<EditionMeta> = authorized
.iter()
.map(|edition| EditionMeta::from(*edition))
.collect();
let selection = fold_head(&metas, judge.floors.get(&entity));
*gapped |= selection.gap;
selection.head.map(|index| authorized[index])
}
fn seal_edition(
edition: &UnsignedEvent,
owner: &Keys,
@@ -216,42 +467,53 @@ mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::edition::{EditionMeta, fold};
use crate::derive::grant_locator;
use crate::edition::fold;
use crate::roles::{Grant, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
let community_id = minted.identity.community_id;
(
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
.expect("derives"),
)
}
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
wraps
.iter()
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
.collect()
}
fn metadata(name: &str) -> CommunityMetadata {
CommunityMetadata {
name: name.to_owned(),
..CommunityMetadata::default()
}
}
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let metadata = CommunityMetadata {
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let at_secs = 1_700_000_000;
let minted = genesis(&owner, &metadata, at_secs).expect("mints");
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// The second client holds only what an invite hands over: the roots,
// the community id and the owner salt.
let read = control_group_key(
&minted.community_root,
&minted.identity.community_id,
ROOT_EPOCH,
)
.expect("derives");
let address = control_signer_group_key(
&minted.control_root,
&minted.identity.community_id,
ROOT_EPOCH,
)
.expect("derives")
.pk();
let mut editions = Vec::new();
for wrap in &minted.wraps {
editions.push(open_edition(wrap, &read, &address, true).expect("opens"));
}
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
@@ -280,8 +542,7 @@ mod tests {
);
}
let state =
CommunityState::from_genesis(&minted, &editions, at_secs * 1_000).expect("projects");
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
@@ -297,4 +558,257 @@ mod tests {
assert_eq!(loaded.heads.len(), 2);
});
}
#[test]
fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
let roster = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
roster.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
let channel_head = roster
.floors
.get(minted.channel_id.as_bytes())
.expect("head");
let (community_wrap, _) = writer
.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
AT + 1,
)
.expect("publishes");
let (channel_wrap, _) = writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
AT + 2,
)
.expect("publishes");
let mut edited = genesis_editions.clone();
edited.extend(open_all(
&[community_wrap, channel_wrap],
&read,
&signer.pk(),
));
let folded = fold_control(
&owner_pk,
&community_id,
&edited,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop two")
);
assert_eq!(
folded
.channels
.get(&minted.channel_id)
.map(|channel| channel.name.as_str()),
Some("lobby")
);
// A relay serving only the editions a client already folded past must not walk
// the community backwards.
let stale = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&folded.floors,
&BTreeSet::new(),
);
assert!(stale.community.is_none());
assert!(stale.channels.is_empty());
let mut state =
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
state.apply_fold(&folded);
assert_eq!(state.channels.len(), 1);
assert_eq!(state.channels[0].name, "lobby");
assert_eq!(state.relays.len(), 1);
}
#[test]
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate();
let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let role_id = RoleId::from_bytes([0x07; 32]);
let role = Role {
role_id,
name: "Mod".to_owned(),
position: 1,
permissions: Permissions(Permissions::MANAGE_METADATA),
scope: RoleScope::Server,
color: 0,
extra: Extra::default(),
};
let (role_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
)
.expect("publishes");
let (grant_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::GRANT,
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
content: &Grant {
member: member.public_key(),
role_ids: vec![role_id],
control_wrap: None,
extra: Extra::default(),
}
.to_content()
.expect("serializes"),
head: None,
citation: None,
},
AT + 2,
)
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
let roster = fold_control(
&owner_pk,
&community_id,
&base,
&Floors::new(),
&BTreeSet::new(),
);
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
let grant = roster
.floors
.get(&grant_locator(
&community_id,
&member.public_key().to_bytes(),
))
.expect("the member's grant folded");
let head = roster.floors.get(community_id.as_bytes()).expect("head");
// The member seals with their own keys and wraps with the staff write key.
let member_writer = ControlWriter {
author: member.public_key(),
read,
signer: signer.clone(),
};
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
)
.expect("publishes");
let (cited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
)
.expect("publishes");
// Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone();
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&forged,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let mut edited_editions = base;
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&edited_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop by mod")
);
}
}
+2 -5
View File
@@ -181,7 +181,8 @@ pub fn control_signer_group_key(
}
/// Member-writable, unlike the Control Plane:
/// a join or a leave is each member's own word.
///
/// - A join or a leave is each member's own word.
pub fn guestbook_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
@@ -210,7 +211,6 @@ pub fn channel_rekey_group_key(
)
}
/// Keyed by the prior `community_root`: the base has no stable key above it
pub fn base_rekey_group_key(
prior_root: &[u8; 32],
community_id: &CommunityId,
@@ -224,13 +224,10 @@ pub fn base_rekey_group_key(
)
}
/// Keyed by the `community_id` alone, so every member past or present resolves
/// the same address and a Refounding cannot strand the grave.
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
}
/// A plain SHA-256 commitment
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
let mut hasher = Sha256::new();
hasher.update(LABEL_COMMUNITY.as_bytes());
+167
View File
@@ -3,6 +3,7 @@ use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::decode_hex_32;
@@ -312,6 +313,92 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
.map(|(index, _)| index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeadSelection {
pub head: Option<usize>,
pub gap: bool,
}
/// The head to prefer for one entity, given what this client already committed to.
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
let Some(floor) = floor else {
return HeadSelection {
head: bootstrap_head(editions),
gap: false,
};
};
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
if anchored.anchored {
return HeadSelection {
head: anchored.head,
gap: anchored.gap,
};
}
if anchored.head.is_none() && !anchored.gap {
return HeadSelection::default();
}
let fork = editions
.iter()
.enumerate()
.filter(|(_, edition)| edition.version == floor.version)
.min_by_key(|(_, edition)| edition.tiebreak_id);
let winner = match fork {
Some((_, edition))
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
{
edition.self_hash
}
_ => {
return HeadSelection {
head: None,
gap: true,
};
}
};
let refolded = fold(editions, floor.version, Some(&winner));
if refolded.anchored {
HeadSelection {
head: refolded.head,
gap: refolded.gap,
}
} else {
HeadSelection {
head: None,
gap: true,
}
}
}
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
impl From<&ParsedEdition> for EntityHead {
fn from(edition: &ParsedEdition) -> Self {
Self {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
}
}
}
/// Every entity's committed head, keyed by coordinate.
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
@@ -366,6 +453,86 @@ fn value<'a>(
mod tests {
use super::*;
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
EditionMeta {
version,
self_hash: [hash; 32],
prev,
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
}
}
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
EntityHead {
entity: [0x11; 32],
version,
self_hash: [hash; 32],
rumor_id: EventId::from_byte_array([rumor; 32]),
}
}
#[test]
fn fold_picks_the_head_from_the_chain_and_the_floor() {
let chain = [
meta(1, None, 0xa1, 1),
meta(2, Some([0xa1; 32]), 0xa2, 2),
meta(3, Some([0xa2; 32]), 0xa3, 3),
];
let folded = fold(&chain, 0, None);
assert_eq!(folded.head, Some(2));
assert!(!folded.gap && folded.anchored);
// A missing link stops the walk at the last contiguous edition.
let gapped = fold(&[chain[0], chain[2]], 0, None);
assert_eq!(gapped.head, Some(0));
assert!(gapped.gap && gapped.anchored);
// Everything below the held floor is a stale relay, not a gap.
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
assert_eq!(stale.head, None);
assert!(!stale.gap && !stale.anchored);
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
assert_eq!(
fold(&fork, 0, None).head,
Some(1),
"the lower rumor id wins"
);
let forked = [fork[0], fork[1], chain[1], chain[2]];
assert_eq!(fold(&forked, 0, None).head, Some(3));
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
// longer resolves is a withholding.
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
assert_eq!(
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
Some(0)
);
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
assert_eq!(refused.head, None);
assert!(refused.gap);
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
assert_eq!(bootstrap_head(&[dangling]), Some(0));
assert_eq!(fold_head(&[dangling], None).head, Some(0));
// A fork at the floor's own version converges to the lower rumor id when that is
// genuinely earlier than what we hold, and the chain above it re-anchors.
let forked = [
meta(2, Some([0xa1; 32]), 0xb2, 3),
meta(3, Some([0xb2; 32]), 0xb3, 4),
];
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
assert_eq!(converged.head, Some(1));
assert!(!converged.gap);
// A fork that is not earlier than the held head is refused.
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
}
#[test]
fn edition_hash_matches_the_cross_client_vector() {
let entity = [0x11u8; 32];
+12 -7
View File
@@ -1,6 +1,7 @@
pub mod control;
pub mod derive;
pub mod edition;
pub mod roles;
pub mod store;
pub mod stream;
@@ -14,6 +15,9 @@ use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
@@ -76,20 +80,21 @@ macro_rules! hex_id {
}
hex_id! {
/// A Community's permanent identity: a self-certifying commitment to its
/// owner's key. It travels inside invites and is itself never on the wire
/// (CORD-02 §1).
/// A self-certifying commitment to the owner's key, carried inside invites and
/// never on the wire.
CommunityId
}
hex_id! {
/// A Channel's identity within its Community (CORD-03).
ChannelId
}
/// A key-rotation counter attached to each Community key.
///
/// It bumps only on a Rekey, a membership change where somebody is removed.
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
File diff suppressed because it is too large Load Diff
+47 -11
View File
@@ -5,9 +5,11 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::control::{ChannelMetadata, CommunityGenesis, CommunityMetadata, ROOT_EPOCH};
use crate::control::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::derive::control_signer_group_key;
use crate::edition::{ParsedEdition, vsk};
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
use crate::stream::OpenedStream;
use crate::{ChannelId, CommunityId, Epoch};
@@ -45,7 +47,6 @@ pub async fn cache_rumor(
Ok(())
}
/// Read a channel's cached rumors.
pub async fn query_rumors(
database: &dyn NostrDatabase,
channel: &ChannelId,
@@ -89,14 +90,6 @@ pub async fn query_rumors(
Ok(rumors)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
@@ -194,6 +187,49 @@ impl CommunityState {
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
}),
None => {}
}
}
}
}
fn state_identifier(id: &CommunityId) -> String {
-2
View File
@@ -140,7 +140,6 @@ pub fn build_rumor_secs(
rumor
}
/// Resolve a rumor's true millisecond time.
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
let mut tag: Option<Option<String>> = None;
@@ -215,7 +214,6 @@ pub fn wrap_seal(
)
}
/// Signs with `signer` while encrypting under `conversation`.
pub fn wrap_seal_with(
seal: &Event,
conversation: &ConversationKey,