Files
coop/docs/concord-usage.md
T
2026-09-17 06:16:01 +00:00

546 lines
22 KiB
Markdown

# Using the Concord backend
`crates/concord` is a protocol crate: derivations, envelopes, folds and the local
state document. It has no GPUI dependency and owns no strings a user reads — the
UI layer decides every rendering. This document is the map from a UI action to
the calls it makes.
A community is addressed by a `community_id` (never on the wire) plus three
secrets: `community_root` (read access — holding it *is* membership),
`control_root` (write access to the Control Plane, held by staff), and per-Channel
keys for private channels. Authority is a roster of owner-rooted signed grants,
folded independently by every client.
## Modules
Files follow the CORD documents. The frozen derivations of Appendix A and the id
vocabulary are shared substrate — every document calls them — so they live outside
`cords` in `utils::derive` (re-exported as `derive`) and `types`.
| Module | Owns |
| --- | --- |
| `cord01` | Private Streams: the seal/wrap envelope and the NIP-44 helpers |
| `cord02` | Communities: identity, epochs, metadata, the Control Plane fold and writer |
| `cord02::guestbook` | Joins, leaves, kicks, snapshots, the member list |
| `cord02::list` | The Community List (a member's own memberships, across devices) |
| `cord03` | Channels: Channel metadata and the Chat Plane |
| `cord04` | Roles: chained editions, parse/hash/fold, the roster, permissions, the banlist |
| `cord04::pins` | Pin Lists, and the key disclosure a keyless reader verifies |
| `cord05` | Invite bundles, links, the Direct Invite, the Invite List |
| `cord06` | Key rotations, refounding, compaction, dissolution |
| `derive` | Every frozen HKDF derivation and coordinate |
| `store` | Local rumor cache, the community state document, relay paging |
`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from
the private `types` module and are re-exported at the crate root.
CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it
lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`).
Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` as
"which key generation". Nothing else in the API needs internal state.
## Creating a community
```rust
use concord::cord02::{self, CommunityMetadata};
use concord::store::{self, CommunityState, save_state};
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?;
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
// minted.wraps — the two owner-signed genesis editions, already sealed
// minted.channel_id — the #general channel
for wrap in &minted.wraps {
client.send_event(wrap).to(&relays).await?;
}
```
The owner then needs the folded state, which is also what every member does on
join:
```rust
use concord::derive::{control_group_key, control_signer_group_key};
use concord::cord04::ParsedEdition;
let read = control_group_key(&minted.community_root, &minted.identity.community_id, Epoch(0))?;
let signer = control_signer_group_key(&minted.control_root, &minted.identity.community_id, Epoch(0))?;
let editions: Vec<ParsedEdition> = minted
.wraps
.iter()
.map(|wrap| cord02::open_edition(wrap, &read, &signer.pk(), true))
.collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
save_state(database, &state).await?;
```
Put the community's relay list into `state.relays` and add those relays to the
client explicitly — coop's client is a gossip client with no background refresh.
## Joining
An invite link resolves to a bundle:
```rust
use concord::cord05::{self, BundleState, invite_bundle_key};
let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
// The crate does no I/O: fetch the naddr from the fragment's relays, then:
let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? {
BundleState::Live(invite) => invite, // validate() already ran
BundleState::Revoked => return Ok(None), // a tombstone at the coordinate
};
```
A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
```rust
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?;
```
Either way the invite carries `community_id`, `owner`, `owner_salt`,
`community_root`, `root_epoch`, `control_pk`, the granted `channels`
(`ChannelGrant { id, key, epoch, name }`) and the relay set. `invite.expired(now_ms)`
is a preview rule: a past expiry still renders, but the join is refused.
Then publish a join so the member list sees the member before any backfill:
```rust
use concord::derive::guestbook_group_key;
use concord::cord02::guestbook;
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?;
client.send_event(&wrap).to(&relays).await?;
```
## Reading the Control Plane
```rust
use concord::cord02::{self, ControlFold};
let editions: Vec<ParsedEdition> = wraps
.iter()
.filter_map(|wrap| cord02::open_edition(wrap, &read, &control_pk, true).ok())
.collect();
let control: ControlFold =
cord02::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned);
state.apply_fold(&control);
```
`ControlFold` is everything the community UI needs:
| Field | Use |
| --- | --- |
| `community` | name, description, icon, banner, `message_expiration` |
| `channels` | the channel list; `deleted: true` means drop it |
| `roles` | `role()`, `roles_of()`, `effective_permissions()`, `is_authorized()`, `is_staff()` |
| `banned` | the banlist |
| `registries` / `is_public()` | each invite creator's live link signers |
| `pins` / `pin_content(id, channel)` | Pin List content per channel |
| `floors` | the committed heads the next fold is judged against |
| `gapped` | a chain hole: refetch the Control Plane before trusting what is missing |
`None` on `community` or a channel means "this client saw no authorized edition",
never "the value is gone" — keep what the state already holds rather than walking
the community backwards. Feed `state.floors()` and `state.banned` into the next
fold; they are its memory.
## Sending a message
```rust
use concord::cord03::{self, build_message};
use concord::derive::channel_group_key;
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?;
client.send_event(&wrap).to(&relays).await?;
```
- `epoch` is the channel's current epoch (`state.channels` carries it). A private
channel derives from its own key instead of `community_root`.
- `timer` is `control.community.message_expiration`; pass `None` when it is off.
The builder attaches the NIP-40 tag and `seal_rumor` mirrors it onto the wrap,
so relays drop the ciphertext too.
- `ephemeral: true` picks kind `21059` for typing indicators. Keep the returned
wrap key if the message may be deleted later — a kind-5 delete needs it.
- That `send_event` is the whole publish path; there is no optimistic echo. Feed
the wrap through the same ingest path the subscription uses so send-then-read
never waits on a relay round trip.
`build_edit`, `build_reaction` and `build_delete` are the same shape, each a rumor
about an existing `EventId` rather than a mutation.
## Reading a channel
```rust
use concord::cord03::{self, fold, plane_keys};
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
let mut rumors = Vec::new();
for wrap in &wraps {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) else {
continue;
};
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue;
};
store::cache_rumor(database, &channel, &opened).await?;
rumors.push(rumor);
}
let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
&& control.roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)
});
```
`ChatMessage` carries `content`, `at_ms`, `edited_at`, `deleted`, `reactions`,
`reply_to` and `thread_root` already resolved. The fold drops expired rumors; a
`deleted` row is still returned so the timeline keeps its shape.
Relay history pages through the local cache:
```rust
let page = store::backfill(client, database, &channel, &held, until, 50).await?;
let cached = store::query_rumors(database, &channel, None, 50).await?;
```
`backfill` walks newest-first across every held epoch, caches what it opens, and
stops on a short page. `query_rumors` is the read path when the group keys are
gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as
any other local sweep — the timer is cooperative, so the local store is the
artifact that has to forget.
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
as an inline row only when its author passes
`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`.
## Membership
```rust
let states = cord02::guestbook::coalesce(&rumors, now_ms, Some(&refounder_pk), |actor, target, citation| {
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
});
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
```
- `observed` is npub → ms for every author this client has seen publish anything
usable, which is what makes a member visible before their Join arrives. Only
count it forward.
- `granted` is every npub the roster ranks; they are members with no Guestbook
entry at all.
- `banned_at` is empty today, so a ban is terminal in the fold. Fill it when the
banlist head's timestamp is plumbed through.
- Removal is three separate actions, composed by the caller: strip the grant
(immediate and cheap), then the kick directive, then — for a ban — the rotation
that actually enforces it.
## Moderation writes
Every Control Plane write goes through one writer and one edition shape:
```rust
let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.clone() };
let head = control.floors.get(entity).cloned();
let (wrap, new_head) = writer.set_community_metadata(
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?;
```
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
from the folded Grant that ranks them (`AuthorityCitation { entity, version, hash }`)
and pass the head from the current fold, so the chain cannot silently fork.
Wrappers: `set_community_metadata`, `set_channel_metadata`, `set_role`,
`set_grant`, `set_banlist`, `set_registry`, `set_pin_list`, plus raw `publish`.
A ban is a `set_banlist` followed by a base rekey; a kick is a `set_grant` with an
empty `role_ids` followed by `cord02::guestbook::build_kick`.
## Pins
```rust
use concord::cord04::pins;
let entry = cord04::pins::build_entry(&opened_message, &plane, &channel)?;
let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
let (wrap, _) = writer.set_pin_list(
&my_keys, &community_id, &channel, &content, head, citation, now_secs)?;
```
Reading is verification: `read_list` decodes either content form (public, or
sealed under the channel key at the named epoch), and
`cord04::pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven
author, words and time — no history and no old keys needed. `read.sealed` means
the list is sealed under an epoch this client never held: show it as unavailable,
and never write from it (`publishable` refuses). `cord04::pins::killed_by(&pin, &delete)`
answers whether a folded kind-5 erases an entry.
## Invites
```rust
use concord::derive::{invite_bundle_key, TOKEN_LEN};
use concord::cord05::{self, InviteEntry, InviteTombstone};
let token: [u8; TOKEN_LEN] = /* 16 bytes from any CSPRNG */;
let bundle_key = invite_bundle_key(&token);
let link_signer = Keys::generate();
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
```
A link is a coordinate plus a fragment: the naddr fetches the bundle, the token
unlocks it, and the fragment names the relays to fetch from.
`cord05::stock_relays()` is what a fragment with no relays of its own means.
The `link_signer` secret is what lets the creator refresh or retire the link, so
keep it against the token in the member's own Invite List — a local document
encrypted to self, exactly like the Community List:
```rust
let mut list = cord05::parse_invite_list(&my_keys, &event)?;
list.entries.push(InviteEntry {
token: HEXLOWER.encode(&token),
signer_sk: link_signer.secret_key().to_secret_hex(),
community_id: invite.community_id,
url,
label: None,
created_at: now_ms,
expires_at: None,
extra: Default::default(),
});
let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
list.tombstones.push(InviteTombstone {
token: HEXLOWER.encode(&token),
community_id: invite.community_id,
extra: Default::default(),
});
```
`merge_invite_lists` merges two devices' copies, `is_live(&token_hex)` answers
whether a link still stands, and `fits()` is the write gate.
## Rekeys, refounding and dissolution
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
gates it, `plan_refounding(epoch)` mints the new pair, and `build_rekey_chunks`
seals one blob per remaining member:
```rust
use concord::derive::epoch_key_commitment;
use concord::cord06::{self, RekeyScope};
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
let plan = cord06::plan_refounding(Epoch(epoch + 1))?;
// A base rotation delivers the new control-plane keys beside the root; a channel
// rotation delivers only that channel's fresh key.
let new_key = plan.new_root;
let (control_pk, control_root) = match scope {
RekeyScope::Base => {
let pk = plan.signer(&community_id)?.pk().to_bytes();
(Some(pk), is_staff.then_some(&plan.new_control_root))
}
RekeyScope::Channel(_) => (None, None),
};
let blobs = members
.iter()
.map(|member| {
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
})
.collect::<Result<Vec<_>, _>>()?;
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let wraps = cord06::build_rekey_chunks(
&my_keys,
&rekey_group,
scope,
plan.epoch,
Epoch(epoch),
&epoch_key_commitment(Epoch(epoch), &community_root),
&blobs,
citation,
false,
now_secs,
)?;
```
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
`collect_rotations(&chunks)`, then `am_i_removed(&rotation, &me)` — which is
`None` until every chunk is held, because an incomplete set is never a removal. A
member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the key
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
Dissolution is owner-only and terminal:
```rust
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?;
// A receiver seals the community read-only on sight.
if cord06::verify_dissolved(&wrap, &identity) {
state.dissolved = true;
}
```
## The Community List
A member's own memberships, synced across their devices:
```rust
use concord::cord02::list;
let material = cord02::list::join_material(&invite, staff.then_some(&control_root));
let mut mine = cord02::list::parse_list_event(&my_keys, &event)?;
mine = cord02::list::merge(mine, cord02::list::CommunityList {
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
..Default::default()
});
let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
```
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
strictly newer join outruns it. `fits()` is the write gate — 50 memberships and
the NIP-44 size cap, both protocol constants.
## GPUI integration
`crates/concord` stays GPUI-free. The UI layer adds a registry global and one
entity per community, and moves every decrypt, verification, fold and I/O off
the foreground thread.
### Entities
Same shape as `ChatRegistry`:
```rust
pub fn init(window: &mut Window, cx: &mut App) {
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx);
}
impl ConcordRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalConcordRegistry>().0.clone()
}
}
```
Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
the account.
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
and the channel list. Views render `Entity<Community>`; no protocol state
lives in a view.
- `CommunityState::apply_fold` is one assignment: run it in the task that
produced the fold and send only the result to the foreground.
- Emit an event on every fold so dependents re-read.
### Foreground and background
A background task never touches an entity. It sends results through a bounded
`flume` channel that a foreground `cx.spawn` drains with `this.update(...)`.
```rust
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
let database = client.database().clone();
// Background: open, verify, fold — no entities.
self.ingress = Some(cx.background_spawn(async move {
for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| plane.group.pk() == wrap.pubkey) else {
continue;
};
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
}
Ok(())
}));
// Foreground: the only place entities change.
self.consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(signal) = signal_rx.recv_async().await {
this.update(cx, |this, cx| this.apply(signal, cx))?;
}
Ok(())
}));
```
- `client.database()` is a `&Arc<dyn NostrDatabase>` and `store::save_state`
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`.
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
to an `Option<Task<_>>` before respawning it; a signer change replaces both
the listener and the consumer.
- `cx.spawn` when the work updates an entity after awaiting, and
`cx.background_spawn` when it only produces a value. A query the foreground awaits can be returned straight out: `fn messages(&self, cx: &App) -> Task<Result<Vec<ChatMessage>, Error>>`.
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
first relay request.
- NIP-46 signing is async: call `signer.get_public_key_async()` /
`sign_event_async` inside the background task. The builders still take
`&Keys`, so run them where device keys are available.
### Subscriptions
A wrap is addressed to a plane, so the plane's public key is the routing key and
one `Filter` per held plane is enough:
```rust
let filter = Filter::new()
.kinds([Kind::from(KIND_WRAP), Kind::from(KIND_WRAP_EPHEMERAL)])
.pubkey(plane.group.pk())
.since(joined_at);
client.subscribe(filter).with_id(sub_id).await?;
```
- `pubkeys([...])` carries every plane of a community on one subscription. Call
`subscribe` again with the new address whenever a join, a channel add or a
rekey fold changes it.
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
kind.
- Watch one epoch ahead: while holding `root_N`, subscribe to
`base_rekey_group_key(&root_N, &community_id, Epoch(N + 1))` and to
`channel_rekey_group_key(&root_N, &channel, Epoch(N + 1))` for each private
channel. A second epoch ahead is not derivable until the new root arrives.
### Tests
`cx.background_executor().timer(..)` for delays, never `smol::Timer`, or
`run_until_parked()` finds nothing left to run. Push a wrap into the channel and
`run_until_parked()` to drive the foreground consumer.
## Not wired up yet
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
the plane whose address it carries, and rebuilding a subscription when a plane's
address changes (join, channel added, rekey folded). GPUI integration above is
the shape to build, not code that exists.
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate
pass over the builders, not a per-call patch.
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
that handler must route by subscription id before any concord subscription goes
live, or every stream wrap lands in the DM trash and raises a toast.
- **No plane key can be persisted yet.** `CommunityState` has nowhere to keep a
key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a
client can verify a rotation and still lose it on restart — history under a
prior root or a prior channel epoch is unreadable until that schema change
lands.