Files
coop/PLAN.md
T
2026-09-16 20:05:51 +07:00

68 KiB
Raw Blame History

Concord support — backend & API plan

Concord is an encrypted community/channel protocol over Nostr: shared-key "Private Streams" (CORD-01), communities with a self-certifying owner id and three planes (CORD-02), public/private channels (CORD-03), an owner-rooted signed roster (CORD-04), invites (CORD-05), rekeys/refoundings (CORD-06) and disappearing messages (CORD-08).

Scope of this plan: backend + public Rust API. No views, no widgets, no copy.

1. Scope

In scope

  • New crates/concord crate: derivations, stream codec, control/chat/guestbook planes, authority fold, ephemeral state, invites, rekeys, dissolution, storage, sync engine.
  • Public API for a future UI: ConcordRegistry + Entity<Community> / Entity<Channel> + events, mirroring the shape of crates/chat.
  • The minimum surgical edits to existing crates required for coexistence (see §11).

Out of scope

  • Any UI work.
  • CORD-07 audio/video. Reserve 23313, the concord/voice-* labels and the voice metadata flag so nothing else claims them, and implement nothing.
  • Pins (CORD-04 §7) ship in the last milestone; the design accounts for vsk 11 early so the fold is not retrofitted.
  • Cross-client interop testing (Vector/Armada/Grimoire). Tracked as follow-up work, not blocking.

2. Sources of truth

Doc What we take from it
CORD-01 Stream event shape, seal forms 20013/20014, encoding rules, binding, deletions
CORD-02 community_id, community_root, control_root, epochs, 3 planes, metadata, invites, Community List, dissolution
CORD-03 Channel keying, metadata, message kinds, channel/epoch binding, threads vs quotes
CORD-04 Editions, vac, the roster, permission bits, banlist, the three removals, pins
CORD-05 Bundle, link (naddr + fragment), relay dictionary, Invite List, Registry, Direct Invite
CORD-06 Rekey blobs, chunking, prevcommit continuity, Refounding, compaction, races
CORD-08 message_expiration, NIP-40 tagging, ingest/purge enforcement, timer notice 1740

Appendix A (derivations) and Appendix B (kinds) of CORD-02 are frozen: every labeled byte and every kind number is part of the wire format. Treat both as constants with golden-vector tests.

Reference implementations for cross-checking behaviour (not for copying code): Vector (crates/vector-core/src/community/v2/*), Armada, Grimoire.

3. Reuse map — nostr-sdk APIs we build on

Verified against the pinned revision (nostr 0.45.4 / nostr-sdk 0.45.2, git b230cec).

Concord need Existing API
NIP-44 under a raw conversation key nostr::nips::nip44::v2::{ConversationKey, encrypt_to_bytes_with_nonce, decrypt_to_bytes}
NIP-44 conversation key for a keypair ConversationKey::derive(&SecretKey, &PublicKey) (self-ECDH for streams)
NIP-44 under a signer nip44::{encrypt, decrypt} (already wrapped by state::UniversalSigner)
Event id recomputation EventId::compute(pubkey, created_at, kind, tags, content), UnsignedEvent::compute_id
Event (de)serialization Event::{from_json, as_json, verify}, UnsignedEvent::from_json
Building events EventBuilder::new(..).tags(..).custom_created_at(..), FinalizeEvent::finalize(&Keys); UnsignedEvent::new(..) for rumors, whose tags are the author's contract and must not be normalized
Tags Tag::{custom, identifier, public_key, expiration}, Tags, SingleLetterTag
Kinds Kind::GiftWrap (1059), `Kind::Custom(21059
Publish Client::send_event(&event).to(relays).ack_policy(AckPolicy::none())
Subscribe Client::subscribe(target).with_id(..).close_on(..), SubscribeAutoCloseOptions, ReqExitPolicy
Backfill Client::fetch_events(target), Client::stream_events(target)
Local persistence Client::database()query(Filter), save_event(&Event)
Invite link parsing Nip19::from_bech32Nip19::Coordinate(Nip19Coordinate)
Signer abstraction state::UniversalSigner (AsyncSignEvent + AsyncNip44)
Relay auth nostr_sdk::{Authenticator, SignerAuthenticator}

Not needed. secp256k1 (use nostr::SecretKey::from_slice + Keys::new), base64 (use data_encoding::BASE64, already a workspace dep), bech32 (NIP-19 is in the SDK), any new storage engine (the client's LMDB database is enough), any new HTTP client.

Dependencies added so far: hkdf = "0.12" at M0 (already in Cargo.lock transitively) and rand = "0.10" at M1 for the NIP-44 nonce, pinned to the exact instance nostr already builds (default-features = false, features std + sys_rng) so nostr's os-rng and ours unify on one rand/getrandom. sha2 and data-encoding were already workspace deps. Two more at M8: the Pin List's per-message key disclosure needs chacha20 = "0.9" (already in the tree because we enable nostr's nip44, which is where chacha20 comes from) and hmac = "0.12" (already in the tree via hkdf) — see §14.8. Zero new crates so far, and all of these are direct-dependency lines only.

Why not depend on Vector's crates. vector-core (published, MIT) holds the only other Rust Concord implementation, in src/community/v2/*. It is not reusable as a dependency, and the "reuse their crypto" argument does not hold:

  • No shared types. It exact-pins the nostr family (nostr = "=0.45.1", nostr-sdk = "=0.45.1", nostr-connect = "=0.45.1", nostr-blossom = "=0.45.0") with the note that a caret range would let a consumer resolve a mixed set, while we track git master (b230cec, 0.45.4 / 0.45.2). A registry 0.45.1 and a git 0.45.4 cannot unify, so a build linking both carries two nostr crates whose Event/Keys/PublicKey/Client are unrelated types.
  • Not wasm-buildable. rusqlite (bundled C SQLite), libc, rustls, reqwest, image, bip39, and a tokio net + rt-multi-thread requirement; VectorCore::init installs a process-global rustls provider and raises the fd limit. Coop's web target is wasm32.
  • It is an application core, not a Concord library. 80k+ lines over 111 files, built on process-global singletons (state::STATE, MY_SECRET_KEY, one app-data dir, one live account, traits::set_event_emitter) and its own SQLite schema, relay pool and blocking listen() loop. Adopting it means handing it the nsec and letting it own the client, the database and the event loop — replacing state, chat and person rather than reusing a component. Its login stores raw secret-key bytes in that global vault, so an account whose key lives in a signer cannot drive it.
  • There is no cryptography to share. Both implementations call the same audited crates — hkdf, sha2, nostr's secp256k1 keypair, and nostr's NIP-44 v2. Vector's comment on that same dependency is "audited RustCrypto crate rather than a hand-rolled construction". Confirmed in M1 by reading community/cipher.rs: it is a ~20-line wrapper that draws an OS nonce, calls nostr::nip44::v2::encrypt_to_bytes_with_nonce, and base64s the result — which is precisely what stream.rs does. Their stream.rs likewise calls nostr_sdk::prelude::nip44::v2::{decrypt_to_bytes, ConversationKey} directly. Our derive.rs holds the frozen info layout and label table, and stream.rs the seal/wrap ordering; both are wire format, not primitives.

So Vector's crates earn their place as an oracle, not a dependency: the golden vectors in derive.rs are their published data, and their community/v2/stream.rs was diffed against our §7 before the codec was written. It agrees on every wire detail, and contributed the ms first-wins rule, the Control Plane's no-ms rumor shape and the rewrap_seal contract.

4. Crate layout

New crate crates/concord, picked up automatically by the crates/* workspace member glob.

crates/concord/
  Cargo.toml
  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: 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
  src/rekey.rs      CORD-06: blob codec, continuity, refounding, compaction, dissolution
  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. 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 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

pub struct CommunityId([u8; 32]);   // sha256 commitment, never on the wire
pub struct ChannelId([u8; 32]);
pub struct Epoch(pub u64);

/// A derived stream: signing keypair + the self-ECDH conversation key that
/// encrypts the wraps. Memoised in a bounded process-wide cache.
pub struct GroupKey { keys: Keys, conversation: ConversationKey }
impl GroupKey {
    pub fn pk(&self) -> PublicKey;
    pub fn pk_hex(&self) -> String;   // lowercase; Debug prints only this, never key material
    pub fn keys(&self) -> &Keys;
    pub fn conversation(&self) -> &ConversationKey;
}

pub enum SealForm { Encrypted, Plaintext }

pub struct OpenedStream {
    pub rumor_id: EventId,
    pub author: PublicKey,      // the seal's verified pubkey
    pub seal_form: SealForm,
    pub seal: Event,            // retained: compaction re-wraps plaintext seals verbatim
    pub wrapper_id: EventId,
    pub at_ms: u64,             // created_at * 1000 + ms tag
    pub rumor: UnsignedEvent,
}

Ordering everywhere uses at_ms, never created_at, and ties break on the lower inner rumor id.

6. Frozen derivations (derive.rs)

Implemented and pinned in crates/concord/src/derive.rs.

fn build_info(label: &str, id: &[u8; 32], epoch: Option<u64>) -> Vec<u8>;   // label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]?
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32];                            // HKDF-SHA256, zero-length salt, L = 32
fn hkdf_to_secret_key(ikm: &[u8], info: &[u8]) -> Result<SecretKey>;        // A.3 scalar_normalize, counter from 0

fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option<u64>) -> Result<GroupKey>;

pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
pub fn control_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;      // read key
pub fn control_signer_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // write key
pub fn guestbook_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
pub fn channel_rekey_group_key(root: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
pub fn base_rekey_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
pub fn dissolved_group_key(id: &CommunityId) -> Result<GroupKey>;                                   // no epoch field

pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId;        // plain SHA-256
pub fn verify_community_id(id: &CommunityId, owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> bool;
pub fn epoch_key_commitment(epoch: Epoch, key: &[u8; 32]) -> [u8; 32];                       // plain SHA-256
pub fn grant_locator(id: &CommunityId, member: &[u8; 32]) -> [u8; 32];
pub fn banlist_locator(id: &CommunityId) -> [u8; 32];
pub fn pins_locator(id: &CommunityId, channel: &ChannelId) -> [u8; 32];
pub fn invite_links_locator(id: &CommunityId, creator: &[u8; 32]) -> [u8; 32];
pub fn recipient_locator(rotator: &[u8; 32], recipient: &[u8; 32], scope: &[u8; 32], epoch: Epoch) -> [u8; 32];
pub fn invite_bundle_key(token: &[u8; 16]) -> [u8; 32];   // raw hkdf32 output; used as a NIP-44 conversation key
pub fn clear_memo();                                       // drop memoised keys on signer change

Appendix A.6, as implemented — ikm / id / epoch. The id is always present (all-zeroes where a label has no meaningful one); the epoch is the only omittable field.

Label ikm id epoch
concord/channel channel key or community_root channel_id yes
concord/control community_root community_id yes
concord/control-signer control_root community_id yes
concord/rekey-pseudonym prior community_root channel_id new epoch
concord/base-rekey-pseudonym prior community_root community_id new epoch
concord/recipient-pseudonym rotator_xonly ‖ recipient_xonly (64 B) scope id new epoch
concord/guestbook community_root community_id yes
concord/dissolved community_id zeroes
concord/grant community_id member x-only
concord/banlist community_id zeroes
concord/pins community_id channel_id
concord/invite-links community_id creator x-only
concord/invite-key token (16 B) zeroes

The CORD-07 concord/voice-* labels and the retired concord/invite-locator / concord/invite-signer are reserved and listed here only: they are underived, and the table stays append-only. Every label a derivation does use has a distinct pinned output, so a duplicated label cannot pass the vectors.

Rules that must be enforced by construction, not by convention:

  • Hex is lowercase everywhere; pubkeys are x-only hex, never bech32; tag numbers are decimal with no leading zeros ("4", never 04/+4).
  • The epoch field is omitted, not zeroed, for labels with no epoch; a test asserts dissolved_group_key differs from the same derivation with Some(0).
  • scalar_normalize retries by appending a counter byte to the same info, starting at 0, and reports exhaustion instead of panicking — so plane keys return Result<GroupKey>.
  • Group keys are memoised by a digest of their inputs, so no deriving secret is a map key, bounded at 1024 entries.

Golden vectors. derive.rs pins all 18 published vectors (the seed and pk for channel, control, control-signer and guestbook; both keyed labels at epoch 0 and at 0x0102030405060708; both rekey labels at epoch 1; dissolved; all four locators; the invite key; the community id; the epoch-key commitment), cross-checked against an independent Python implementation (RFC 5869 HKDF plus pure-integer secp256k1) before being frozen. One vector is missing upstream — pins_locator — so we mint it from our own implementation and pin it, flagged in the test as self-referential. Changing any pinned value means the wire format changed.

7. Stream codec (stream.rs) — implemented in M1

pub const KIND_WRAP: u16 = 1059;
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;

pub enum SealForm { Encrypted, Plaintext }

pub struct OpenedStream {
    pub rumor_id: EventId,
    pub author: PublicKey,
    pub seal_form: SealForm,
    pub seal: Event,
    pub wrapper_id: EventId,
    pub at_ms: u64,
    pub rumor: UnsignedEvent,
}

pub fn split_ms(at_ms: u64) -> (u64, u16);
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError>;

pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result<String, StreamError>;
pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author: &Keys) -> Result<Event, StreamError>;
pub fn wrap_seal(seal: &Event, group: &GroupKey, wrap_kind: u16, at: Timestamp, extra: &[Tag]) -> Result<(Event, Keys), StreamError>;
pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, at: Timestamp) -> Result<(Event, Keys), StreamError>;

pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError>;
pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_signature: bool) -> Result<OpenedStream, StreamError>;

pub fn build_rumor_ms(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_ms: u64) -> UnsignedEvent;
pub fn build_rumor_secs(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_secs: u64) -> UnsignedEvent;

pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag>;
pub fn check_channel_binding(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<(), StreamError>;

StreamError is a typed enum, not anyhow: the caller has to tell a drop from a fatal, and M1's acceptance criterion is that rejections happen in the documented order.

Refinements against the draft this plan opened with, decided after diffing Vector's crates/vector-core/src/community/v2/stream.rs (Concord has no crate of its own there, and envelope.rs does not exist):

  • build_rumor became build_rumor_ms plus build_rumor_secs. The Control Plane edition carries no ms tag, because editions fold by version, not by time.
  • rewrap_seal was added to the codec. Without it the plaintext-seal carry-forward has no expression, and M7's compaction is its only caller.
  • NIP-44 is reached through nostr's own nip44::v2::{encrypt_to_bytes_with_nonce, decrypt_to_bytes, ConversationKey}, with a fresh OS nonce per message and data_encoding::BASE64 for carriage. This is exactly what Vector does; there is no cryptography of theirs to reuse.

Design points that are easy to get wrong:

  • The wrap is signed by the stream key with a random ephemeral p tag — NIP-59 reversed. extra is how the caller mirrors a NIP-40 expiration onto the wrap.
  • The seal is signed by the real author and carries created_at equal to the rumor's. It is never published bare.
  • Control plane must use the plaintext seal; chat, guestbook and rekey planes must use the encrypted one. Each plane asserts its own form at both ends.
  • The control plane is a write-restricted stream: the wrap key derives from control_root while the content is encrypted under the community_root-derived conversation key. open_wrap_at takes the two halves separately for this reason.
  • Open order: kind → address match → wrap signature (only when verify_wrap_signature) → NIP-44 open → seal kind → seal signature → rumor parse → rumor.pubkey == seal.pubkey → recompute the rumor id and reject a mismatch → strict ms resolve.
  • Enforce the 65 535-byte NIP-44 cap at every nesting layer before publishing.
  • Do not verify the wrap signature for ordinary planes: every reader holds the group key, so the signature proves nothing. It matters only for the restricted control plane and for rekeys.
  • The ephemeral wrap keypair is returned to the caller so a client may NIP-09-scrub its own wrap later.
  • A duplicate ms tag takes the first value; it is not rejected. Rejecting made Vector and Armada disagree on whether the event exists, and because ms orders messages that divergence reached membership. ms is the publisher's own value, so conceding a second tag grants an attacker no reach a single one did not. A present-but-valueless ms, or one that is not a lone canonical decimal in 0..=999, is BadMs and the event is dropped, never clamped — u64::from_str alone would accept a leading +, a second encoding a strict peer rejects, so the digit check comes first.
  • A binding tag that names the same key twice is rejected outright, since first-match would then be the reader's choice rather than the author's; a valueless tag counts as absent, so a true absence reports MissingTag.

8. Planes, state and folds

8.1 Editions, authority and the control fold (edition.rs, roles.rs)

pub const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";   // frozen, cross-client (27 bytes)

// sha256( u64be(len(label)) ‖ label ‖ entity[32] ‖ u64be(version)
//         ‖ flag[1] ‖ prev[32] ‖ u64be(len(content)) ‖ content )
// `prev` is always 33 bytes: 0x01 ‖ hash, or 0x00 ‖ zeroes when absent.
// The hash commits to no actor: identity enters only via the rumor id.
pub fn edition_hash(entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8]) -> [u8; 32];
pub struct ParsedEdition { author: PublicKey, subkind: String, entity: [u8; 32], version: u64,
                           prev: Option<[u8; 32]>, citation: Option<AuthorityCitation>,
                           content: String, self_hash: [u8; 32], rumor_id: EventId };
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError>;

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.
  • Versions start at 1, not 0 (CORD-04 §1: "climbs from 1"). Genesis is (version 1, prev None) for both entities.
  • 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. 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 0community_id, 1role_id, 2channel_id, 3grant_locator, 4banlist_locator, 8invite_links_locator, 11pins_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).
// 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 as implemented:

  • 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.
  • 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}), 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.

The Control Plane's whole projection is one call:

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:

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)

pub enum GuestbookEntry {
    Join  { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> },
    Leave { member: PublicKey, at_ms: u64 },
    Kick  { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option<AuthorityCitation> },
    Snapshot { refounder: PublicKey, members: Vec<PublicKey>, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 },
}

pub fn coalesce(events: &[GuestbookEvent], now_ms: u64, snapshot_authority: Option<&PublicKey>,
                can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool)
    -> BTreeMap<PublicKey, MemberState>;

pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
                           observed: &BTreeMap<PublicKey, u64>,          // author → newest ms published
                           banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>,
                           refound: Option<&Refound>) -> BTreeSet<PublicKey>;
  • Entries dated more than an hour ahead of local time are dropped. An ms outside 0..999 drops the entry rather than being interpreted.
  • Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id.
  • A Kick counts only when its signer holds KICK and outranks the target.
  • A Snapshot counts only from the npub whose Refounding minted the epoch. There is deliberately no owner fallback.
  • The member list is coalesced Joined observed authors banlist, and observation counts forward only (an author re-enters on activity newer than their latest Leave/Kick/Ban). A Grant holder with ms == 0 is present by construction.

8.4 Chat plane (chat.rs)

pub struct ChatMessage {
    pub id: EventId,               // recomputed rumor id
    pub author: PublicKey,
    pub channel: ChannelId,
    pub epoch: Epoch,
    pub kind: Kind,                // 9 | 1111 | 3302 | 1740 | 15
    pub content: String,
    pub media: Vec<SharedUri>,
    pub mentions: Vec<Mention>,
    pub reply_to: Option<EventId>, // lowercase `e`/`q`
    pub thread_root: Option<EventId>, // uppercase `E` for 1111
    pub at_ms: u64,
    pub expiration: Option<Timestamp>,
    pub edited_at: Option<u64>,    // folded from 3302
    pub deleted: bool,             // folded from 5
    pub reactions: BTreeMap<PublicKey, String>,
}

Sends funnel through one function so the rules cannot drift:

fn publish_chat(store, client, community, channel, epoch, group, rumor, at_ms, ephemeral) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;

It builds the seal + wrap, mirrors the NIP-40 tag onto the wrap for durable kinds, publishes via send_event(..).to(relays), retains the ephemeral wrap key for later NIP-09 scrubbing, and locally echoes its own wrap through the same ingest path so send-then-read works without waiting on a relay round-trip.

Disappearing messages (CORD-08) live here: message_expiration is read from the folded metadata, ["expiration", created_at + t] is attached to every durable Chat rumor and to the wrap, kinds 5 and 1740 are exempt, ingest refuses an already-expired rumor, a periodic sweep purges stored ones, and the kind 1740 timer notice renders only when its author holds MANAGE_METADATA.

8.5 Invites (invite.rs)

pub struct CommunityInvite { community_id, owner, owner_salt, community_root, root_epoch,
                             control_pk: Option<String>, channels: Vec<ChannelGrant>,
                             relays: Vec<String>, name: String, icon: Option<ImageRef>,
                             expires_at: Option<u64>, creator_npub: Option<String>, label: Option<String>,
                             extra: Map<String, Value> }
impl CommunityInvite { pub fn validate(&self) -> Result<(), InviteError>;   // recompute community_id
                       pub fn expired(&self, now_ms: u64) -> bool; }

pub fn build_bundle(token: &[u8; 16], link_signer: &Keys, invite: &CommunityInvite) -> Result<Event, InviteError>; // 33301, d = ""
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError>;                                        // vsk 9
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError>;
pub fn encode_fragment(relays: &[RelayUrl], token: &[u8; 16]) -> String;   // version byte 4, flags, ≤ 3 relays, base64url
pub fn decode_fragment(fragment: &str) -> Result<(Vec<RelayUrl>, [u8; 16]), InviteError>;
pub fn build_direct_invite(receiver: &PublicKey, invite: &CommunityInvite, signer: &UniversalSigner) -> Task<Result<Event, Error>>; // 3313 rumor → 13 seal → k-tagged 1059

The link rides naddr (Nip19Coordinate for kind 33301, link signer, empty d) in the path and the token + bootstrap relays in the fragment. A fragment is never sent to a server. The bundle is decrypted with invite_bundle_key(token), and the joiner must recompute community_id from owner + owner_salt.

Bounds before allocation: reject a bundle with more than 256 channels, truncate the relay list to 5, refuse an expired one.

8.6 Rekeys and refoundings (rekey.rs)

pub enum RekeyScope { Channel(ChannelId), Base }
pub fn encode_blob_plaintext(scope, epoch, new_root, control_pk, control_root) -> Vec<u8>;   // 72 | 104 | 136 bytes
pub fn parse_blob_plaintext(bytes: &[u8], scope, epoch) -> Result<KeyDelivery, RekeyError>;
pub fn build_rekey_rumor(rotator, scope, new_epoch, prev_epoch, prev_commit, blobs, chunk) -> UnsignedEvent;
pub fn plan_refounding(fold, removed: &[PublicKey]) -> Result<Refounding, RekeyError>;
pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>;   // re-wrap heads verbatim, plaintext seals preserved
  • The subscription for rekeys is precomputed from the next epoch's address, per private channel and once for the base.
  • A receiver accepts a key only after: locating its blob, decrypting with the rotator↔recipient conversation key, checking the bound scope and epoch inside the plaintext, and matching prevcommit against the key it currently holds.
  • Only after holding all n chunks of one (rotator, newepoch, prevcommit) set, with none containing its locator, may a client conclude it was removed.
  • Send cap 80 blobs per event, accept cap 120 (Vector's documented erratum: the CORD-01 double envelope pushes 120 blobs past a 64 KB relay limit). Record the reason in a comment so nobody "fixes" it back.
  • Compacted control heads are re-wrapped with their original signature intact, which is exactly why the control plane uses the plaintext seal.
  • Two concurrent refoundings converge on the lexicographically lowest new base key; the heal is down-only.
  • Authority: a channel rekey needs MANAGE_CHANNELS, a refounding needs BAN, and in both the rotator must strictly outrank every removed target. Holding a key is never authority.

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, state document in M2, fold bridge in M3

Three layers, no new storage engine:

  1. Raw wraps (kind 1059) are persisted automatically by the SDK's relay pool when a subscription or fetch matches a filter. Nothing to write.
  2. Opened rumors are cached locally as NIP-78 Kind::ApplicationSpecificData events signed by a session-local keypair, exactly like chat::set_rumor. Tags: ["d", rumor_id] (replace key), ["c", channel_hex], ["p", author], ["k", kind], ["e", wrap_id], ["t", "concord"]. Contents are the rumor JSON.
    • The c/t keys deliberately differ from chat's r key so the two message namespaces can never collide in one database.
    • created_at is the message's own second (from at_ms), not the wall clock. Otherwise until and the ordering would page on cache time rather than message time.
    • The read path dedupes by rumor id and keeps the newest created_at, because the local signing key changes per session and each session leaves its own copy. The query therefore carries no filter limit — every copy has to be in hand before they can be collapsed — and the cap is applied to the deduplicated result instead.
    • The layer takes &dyn NostrDatabase, not &Client: it is local-only, which keeps it testable without a relay or a GPUI context.
pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<()>;
pub async fn query_rumors(database: &dyn NostrDatabase, channel: &ChannelId, until: Option<Timestamp>, limit: usize) -> Result<Vec<UnsignedEvent>>;

query_rumors returns UnsignedEvent, not Event: the cached payload is a rumor, which is also what OpenedStream carries, so the caller never has to re-parse.

Deferred to M4: the relay-paging backfill. It is network history paging whose "step past the same-second wall" policy belongs with the sync engine, and M1 has no subscription to test it against.

  1. Community state — one local document per community, Kind::ApplicationSpecificData with ["d", "concord/<community_id>"]:
pub struct CommunityState {
    pub id: CommunityId,
    pub owner: PublicKey,
    pub owner_salt: [u8; 32],
    pub community_root: [u8; 32],
    pub root_epoch: Epoch,
    pub control_root: Option<[u8; 32]>,       // present iff the holder is staff
    pub control_pks: BTreeMap<u64, PublicKey>, // epoch → the plane's signer address
    pub channels: Vec<ChannelKeyRef>,          // id, name, private, epoch
    pub relays: Vec<RelayUrl>,
    pub heads: Vec<EntityHead>,                // entity, version, self_hash, inner id
    pub added_at_ms: u64,
}

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:

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.

10. Sync engine and GPUI conventions

ConcordRegistry mirrors ChatRegistry's shape exactly: a foreground GPUI entity holding Entity<Community> handles, a flume signal bus, one background notification listener, one foreground consumer, and task slots that are cleared when the signer changes.

Subscription. Community relays come from the folded metadata. init/join add them to the client (client.add_relay(url).and_connect()), then:

let filter = Filter::new()
    .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
    .authors(plane_authors)          // guestbook, control signer, all held channel planes,
                                     // rekey addresses for epoch + 1, dissolved address
    .since(Timestamp::from_secs(now - FRESH_WINDOW))
    .limit(0);                       // live tail only; history comes from backfill
client.subscribe(target).with_id(SubscriptionId::new(CONCORD_SUB)).await?;

Targeted subscribe against the community relays, with a pool-wide subscribe as the fallback path. Rebuild idempotently whenever a plane's address changes (join, channel added, rekey folded).

Routing. dispatch matches on the subscription_id carried in RelayMessage::Event, dedupes by wrap id (both subscriptions and several relays deliver the same wrap), then recognises the plane by wrap author against the derived addresses it holds — never by trial decryption. Recognition order: held channel planes → guestbook → control signer → rekey addresses → dissolved.

Ingest pipeline. Unwrap, verify and fold all happen inside cx.background_spawn, never on the foreground thread: secp256k1 verification per edition and per seal is far too expensive for the UI thread.

enum Signal {
    Chat { community: CommunityId, channel: ChannelId, message: Box<ChatMessage> },
    Control { community: CommunityId, heads: Vec<EntityHead>, roster: Box<FoldedRoster> },
    Guestbook { community: CommunityId, members: BTreeSet<PublicKey> },
    Rekey { community: CommunityId, scope: RekeyScope, epoch: Epoch },
    Dissolved(CommunityId),
    Eose(SubscriptionId),
    Error(ConcordError),
}

The consumer is cx.spawn(async move |this, cx| { while let Ok(signal) = rx.recv_async().await { this.update(cx, |this, cx| this.apply(signal, cx))?; } }), which updates entities and calls cx.notify().

Rules taken from the project guidelines:

  • Crypto, folding, database queries and network I/O only in cx.background_spawn.
  • Foreground tasks are cx.spawn with this.update(cx, ..); any entity update happens there, and the inner cx is always used.
  • Tasks are stored in fields (tasks, listener, consumer) so they are cancelled on signer change and dropped with the registry. detach() only for genuinely fire-and-forget work such as the local state save.
  • Long-running paging is bounded by explicit page and step caps, not by unbounded loops.
  • Every fallible path returns Result and surfaces through ConcordEvent::Error; nothing is silently swallowed.

Registry API.

pub fn init(window: &mut Window, cx: &mut App);
pub struct ConcordRegistry { /* … */ }
impl ConcordRegistry {
    pub fn global(cx: &App) -> Entity<Self>;
    pub fn loading(&self) -> bool;
    pub fn communities(&self) -> Vec<Entity<Community>>;
    pub fn community(&self, id: &CommunityId, cx: &App) -> Option<WeakEntity<Community>>;
    pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Community>>;

    pub fn create(&mut self, params: CommunityParams, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
    pub fn join(&mut self, link: &str, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
    pub fn accept_direct_invite(&mut self, rumor: &UnsignedEvent, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
    pub fn leave(&mut self, id: &CommunityId, cx: &mut Context<Self>);
    pub fn discard_invite(&mut self, id: &CommunityId, cx: &mut Context<Self>);
    pub fn refresh(&mut self, id: &CommunityId, cx: &mut Context<Self>);
    pub fn shutdown(&mut self, cx: &mut Context<Self>);   // halt subscriptions, keep our own state
}

Community API (Entity<Community>, EventEmitter<CommunityEvent>):

pub fn id(&self) -> CommunityId;
pub fn owner(&self) -> PublicKey;
pub fn name(&self) -> SharedString;  pub fn description(&self) -> Option<SharedString>;
pub fn icon(&self) -> Option<ImageRef>;
pub fn relays(&self) -> Vec<RelayUrl>;
pub fn epoch(&self) -> Epoch;
pub fn dissolved(&self) -> bool;
pub fn channels(&self) -> Vec<Entity<Channel>>;
pub fn channel(&self, id: &ChannelId, cx: &App) -> Option<WeakEntity<Channel>>;
pub fn members(&self) -> BTreeSet<PublicKey>;
pub fn banned(&self) -> BTreeSet<PublicKey>;
pub fn roles(&self) -> &CommunityRoles;
pub fn permissions(&self, member: &PublicKey) -> u64;
pub fn is_staff(&self, member: &PublicKey) -> bool;
pub fn message_expiration(&self) -> Option<u64>;

// authority actions — each returns a publish task and nothing optimistic
pub fn set_metadata(&mut self, meta: CommunityMetadata, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn create_channel(&mut self, name: &str, private: bool, cx: &mut Context<Self>) -> Task<Result<ChannelId, Error>>;
pub fn edit_channel(&mut self, id: &ChannelId, meta: ChannelMetadata, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn create_role(&mut self, role: Role, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn assign_roles(&mut self, member: &PublicKey, roles: &[[u8; 32]], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn ban(&mut self, members: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn unban(&mut self, members: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn kick(&mut self, member: &PublicKey, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn rekey_channel(&mut self, id: &ChannelId, removed: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn refound(&mut self, removed: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn dissolve(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn create_invite(&mut self, params: InviteParams, cx: &mut Context<Self>) -> Task<Result<String, Error>>;
pub fn revoke_invite(&mut self, token: &[u8; 16], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn direct_invite(&mut self, receiver: &PublicKey, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn save_community_list(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>>;   // kind 13302, multi-device sync

Channel API (Entity<Channel>): id, name, private, epoch, deleted, plus

pub fn messages(&self, until: Option<Timestamp>, limit: usize, cx: &App) -> Task<Result<Vec<ChatMessage>, Error>>;
pub fn send(&self, content: &str, reply_to: Option<EventId>, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn send_file(&self, file: FileAttachment, reply_to: Option<EventId>, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn edit(&self, id: EventId, content: &str, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn delete(&self, id: EventId, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn react(&self, id: EventId, emoji: &str, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn typing(&self, cx: &App) -> Task<Result<(), Error>>;          // kind 23311, ephemeral
pub fn pin(&self, id: EventId, cx: &App) -> Task<Result<(), Error>>; // vsk 11, PIN_MESSAGES

CommunityEvent and ChannelEvent mirror ChatEvent: one variant per thing the UI has to react to (Updated, Members, Added, Removed, Dissolved, Error, plus channel-level Incoming, Reload).

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 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.

12. Security invariants to test, not to assume

Each of these has burned a real implementation, or is a documented cross-client trap:

  • Recompute every rumor id and reject a claimed mismatch; never trust an embedded id.
  • Require rumor.pubkey == seal.pubkey.
  • Require the plaintext seal form on Control and the encrypted form on Chat/Guestbook/Rekey — a strict reader must drop a mis-sealed edition rather than fold a chain a later compaction would fork.
  • Check channel and epoch against the plane whose key opened the wrap; reject duplicates of either tag.
  • Reject duplicate vsk/eid/ev/ep/vac tags; require decimal-with-no-leading-zeros on every numeric tag. The one exception is ms, which takes its first value rather than erroring — see §7 for why rejecting it reached membership.
  • Refuse a tombstone whose eid is not this community's id.
  • Adopt a control_root from a Grant only if it derives to the control_pk held for that epoch; adopt a rekey blob only if its bound plaintext matches the scope and epoch and its prevcommit matches the key currently held.
  • Never conclude removal from a partial rekey chunk set.
  • Drop guestbook entries more than an hour in the future; treat an out-of-range ms as malformed, not as an interpretation opportunity.
  • Never honour a Snapshot from anyone but the refounder of that epoch.
  • 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

# Deliverable Done when
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 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
M7 Rekeys + refounding + dissolution a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused
M8 Pins + disappearing messages + hardening pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet

Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone.

M1 closed with cargo test -p concord (5 tests), cargo clippy -p concord --all-targets, and cargo fmt -p concord --check all clean. rand was added to the workspace pinned to the same 0.10.2 instance nostr already builds, so Cargo.lock gained no package.

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

  1. Community List kind. CORD-02 §8 specifies 13302, replaceable. Vector has retired it in favour of fragmented 33302, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. We implement 13302 per spec, enforce the 50-membership cap and pre-publish size check, and treat 33302 as an interop follow-up. Confirm with Armada before writing the multi-device code.
  2. NIP-42 for stream-authored REQs. Relays that gate kind 1059 by author (for example ditto-relay's AUTH_KINDS) need an AUTH event signed by that plane's derived key. nostr-sdk's Authenticator is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Vector's answer is a dedicated stream-auth responder installed on the client (community/v2/streamauth), primed before any relay interaction, which retains the relay's challenge so plane keys registering later can still answer it: a gating relay challenges once per connection and will not re-challenge an authed one, so a responder attached later never gets the chance. Read that module before deciding; the alternative options remain a per-REQ auth hook upstream or documenting the limitation.
  3. invite_bundle_key — resolved in M0. Appendix A.6 was read in full: the raw HKDF output is the NIP-44 conversation key, and the derivation is now pinned by a vector.
  4. pins_locator has no upstream vector. Resolved in M0 by minting one from our own implementation and flagging it self-referential in the test.
  5. Relay set. Up to 5 recommended, and both reads and writes fan out across them. Coop's client is a gossip client with no_background_refresh, so community relays must be added explicitly and re-added on metadata change.
  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

  • Unit, pure: derivations against golden vectors, edition hash, fold, coalesce, memberlist, blob codec, caps and rejection paths. These need no GPUI context and should be exhaustive — they are where cross-client divergence is caught.
  • Integration, GPUI: TestAppContext with two registries sharing an in-memory database, driving wraps through the ingest path; timeouts and delays use cx.background_executor().timer(..) per the project guidelines, never smol::Timer.
  • Round-trip: every builder paired with its parser, asserting the parse produces the identical structure, including unknown-field round-tripping on all content types.
  • Negative: every bullet in §12 gets a test that constructs the hostile input and asserts the drop.