diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 0412d7d3..4a883185 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -92,14 +92,13 @@ pub struct Snapshot { pub members: BTreeSet, } -/// Mints a community owned by `signer` and persists it locally. pub async fn create( client: &Client, signer: &S, metadata: &cord02::CommunityMetadata, ) -> Result where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { let at_secs = Timestamp::now().as_secs(); let genesis = cord02::genesis(signer, metadata, at_secs).await?; @@ -112,15 +111,108 @@ where for wrap in &genesis.wraps { editions.push(cord02::open_edition(wrap, &read, &address, true)?); - client.database().save_event(wrap).await?; } let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?; store::save_state(client, &state).await?; + publish_wraps(client, &genesis.wraps, &state.relays).await; + + if let Err(error) = record_membership(client, signer, &state, &metadata.name).await { + log::warn!( + "community {}: recording the membership failed: {error}", + state.id.to_hex() + ); + } + Ok(state) } +/// Best-effort publication of the genesis wraps to the community's relays. +async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { + for url in relays { + if let Err(error) = client.add_relay(url).and_connect().await { + log::warn!("community genesis: failed to add relay {url}: {error}"); + } + } + + for wrap in wraps { + let sent = if relays.is_empty() { + client.send_event(wrap).broadcast().await + } else { + client.send_event(wrap).to(relays.iter().cloned()).await + }; + + match sent { + Ok(output) if output.failed.is_empty() => {} + Ok(output) => log::warn!( + "community genesis: {} relay(s) rejected {}", + output.failed.len(), + wrap.id + ), + Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id), + } + } +} + +async fn record_membership( + client: &Client, + signer: &S, + state: &CommunityState, + name: &str, +) -> Result<()> +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let self_pk = signer.get_public_key_async().await?; + let held = load_list(client, signer, self_pk).await?; + + let frags = held.as_ref().map_or(1, |list| list.frags); + + if frags > 1 { + log::warn!( + "community {}: the list spans {frags} fragments; deferring the membership write", + state.id.to_hex() + ); + return Ok(()); + } + + let entry = store::list_entry(state, name); + let list = match held { + Some(held) => held.joined(entry), + None => CommunityList::default().joined(entry), + }; + + let previous = newest_fragment_at(client, self_pk).await?; + let now = Timestamp::now().as_secs(); + let at_secs = previous.map_or(now, |previous| now.max(previous.as_secs() + 1)); + + let event = cord02::list::build_list_event(signer, &list, 0, at_secs).await?; + publish_list(client, &event).await; + + Ok(()) +} + +async fn publish_list(client: &Client, event: &Event) { + match client.send_event(event).to_nip65().await { + Ok(output) if output.failed.is_empty() => {} + Ok(output) => log::warn!( + "community list: {} relay(s) rejected the publish", + output.failed.len() + ), + Err(error) => log::warn!("community list: publish failed: {error}"), + } +} + +/// The newest `created_at` the account holds across its list fragments. +async fn newest_fragment_at(client: &Client, self_pk: PublicKey) -> Result> { + Ok(newest_fragments(client, self_pk) + .await? + .into_values() + .map(|event| event.created_at) + .max()) +} + /// The subscription id carrying the account's own Community List. pub const LIST_SUBSCRIPTION: &str = "concord/list"; @@ -249,12 +341,8 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { held } -/// Every fragment of the account's list in the local database, merged. -async fn load_list( - client: &Client, - signer: &UniversalSigner, - self_pk: PublicKey, -) -> Result> { +/// The newest held copy of each fragment, keyed by its `d` index. +async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result> { let filter = Filter::new() .kind(Kind::Custom(KIND_COMMUNITY_LIST)) .author(self_pk); @@ -274,9 +362,21 @@ async fn load_list( } } + Ok(newest) +} + +/// Every fragment of the account's list in the local database, merged. +async fn load_list( + client: &Client, + signer: &S, + self_pk: PublicKey, +) -> Result> +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ let mut merged: Option = None; - for event in newest.into_values() { + for event in newest_fragments(client, self_pk).await?.into_values() { match cord02::list::parse_list_event(signer, &event).await { Ok(list) => { merged = Some(match merged { @@ -464,22 +564,55 @@ mod tests { .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) ); - // The filter author-lists every plane, so the subscription actually - // reaches the events the fold reads. let filter = subscription_filter(&planes); let addresses: BTreeSet = planes.iter().map(|plane| plane.address).collect(); assert_eq!(filter.authors, Some(addresses)); assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); } - fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata { + fn metadata(name: &str) -> cord02::CommunityMetadata { cord02::CommunityMetadata { name: name.to_owned(), - relays: vec![relay.to_owned()], ..cord02::CommunityMetadata::default() } } + fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState { + CommunityState { + id, + owner: Keys::generate().public_key(), + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: Some([0x03; 32]), + control_pks: BTreeMap::from([(0, control_pk)]), + channels: vec![concord::store::ChannelKeyRef { + id: ChannelId::from_bytes([0x9c; 32]), + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + }], + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + dissolved: false, + added_at_ms: 1_700_000_000_000, + } + } + + /// Puts a List in the database as the account's own fragment, exactly as a + /// relay would have delivered it. + async fn store_fragment(client: &Client, signer: &S, list: &CommunityList) + where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, + { + let event = cord02::list::build_list_event(signer, list, 0, 1_700_000_000) + .await + .expect("builds"); + client.database().save_event(&event).await.expect("saves"); + } + /// What `CommunityRegistry` needs from a created community: a state document /// `load` finds, a control plane the subscription filter actually addresses, /// and a fold that survives an inbound control edit. @@ -490,7 +623,7 @@ mod tests { let keys = Keys::generate(); let signer = UniversalSigner::new(keys.clone()); - let created = create(&client, &signer, &metadata("coop", "wss://relay.example")) + let created = create(&client, &signer, &metadata("coop")) .await .expect("creates"); @@ -547,7 +680,7 @@ mod tests { .set_community_metadata( &keys, &created.id, - &metadata("coop two", "wss://relay.example"), + &metadata("coop two"), Some(community_head), None, Timestamp::now().as_secs() + 1, @@ -570,4 +703,185 @@ mod tests { ); }); } + + /// The other half of `create`: the membership must reach the account's + /// Community List, and a later create must union into it rather than replace + /// it (CORD-02 §8 read-modify-write). + #[test] + fn creating_a_community_records_the_membership_in_the_list() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + let self_pk = keys.public_key(); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + let list = load_list(&client, &signer, self_pk) + .await + .expect("reads") + .expect("a list"); + + assert_eq!(list.frags, 1); + assert!(list.is_complete([0]), "the whole list is one fragment"); + assert!(list.is_live(&created.id)); + + let entry = list + .entries + .iter() + .find(|entry| entry.community_id == created.id) + .expect("the membership"); + assert_eq!( + entry.seed, entry.current, + "a fresh membership has one anchor" + ); + assert_eq!(entry.current.name, "coop"); + assert_eq!(entry.current.owner, created.owner); + assert_eq!(entry.current.root_epoch, created.root_epoch); + assert_eq!( + entry.current.control_pk, + created.control_pks.get(&0).copied() + ); + assert!( + entry.current.control_root.is_some(), + "the owner holds the control root" + ); + assert_eq!(entry.current.channels.len(), created.channels.len()); + assert_eq!(entry.added_at, created.added_at_ms); + + // A second create unions into the same document: the first + // membership survives and both are live. + let second = create(&client, &signer, &metadata("second")) + .await + .expect("creates"); + + let grown = load_list(&client, &signer, self_pk) + .await + .expect("reads") + .expect("a list"); + + assert!(grown.is_live(&created.id)); + assert!(grown.is_live(&second.id)); + assert_eq!(grown.entries.len(), 2); + }); + } + + /// The discovery fix: a membership the List carries is materialized even when + /// no state document has ever been written for it. + #[test] + fn load_materializes_a_membership_the_list_carries_with_no_state_document() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let listed = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let list = CommunityList::default().joined(store::list_entry(&listed, "coop")); + store_fragment(&client, &signer, &list).await; + + assert!( + store::load_state(&client, &listed.id) + .await + .expect("reads") + .is_none() + ); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let materialized = loaded + .iter() + .find(|state| state.id == listed.id) + .expect("the list materializes the community"); + + assert_eq!(materialized.owner, listed.owner); + assert_eq!(materialized.community_root, listed.community_root); + assert_eq!(materialized.control_root, listed.control_root); + assert_eq!(materialized.control_pks, listed.control_pks); + assert_eq!(materialized.channels, listed.channels); + assert_eq!(materialized.added_at_ms, listed.added_at_ms); + + // Discovery writes the document, so the next load is warm. + assert_eq!( + store::load_state(&client, &listed.id) + .await + .expect("reads") + .map(|state| state.id), + Some(listed.id) + ); + }); + } + + /// Absence from the List is never a fact (§8): a held membership the List + /// does not mention survives alongside the one it does. + #[test] + fn load_keeps_a_local_membership_the_list_does_not_mention() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let listed = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let local = held( + CommunityId::from_bytes([0x43; 32]), + Keys::generate().public_key(), + ); + + let list = CommunityList::default().joined(store::list_entry(&listed, "listed")); + store_fragment(&client, &signer, &list).await; + store::save_state(&client, &local).await.expect("saves"); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let ids: BTreeSet = loaded.iter().map(|state| state.id).collect(); + + assert!(ids.contains(&listed.id)); + assert!(ids.contains(&local.id)); + }); + } + + /// Only a tombstone subtracts a membership (§8). + #[test] + fn a_tombstone_drops_a_held_membership() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let local = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + store::save_state(&client, &local).await.expect("saves"); + + let list = CommunityList::default().tombstoned(local.id, u64::MAX); + store_fragment(&client, &signer, &list).await; + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + + assert!(loaded.iter().all(|state| state.id != local.id)); + }); + } + + /// The `concord/list` id must not be read as a community's subscription, or + /// every list event would refresh a community instead of triggering `load`. + #[test] + fn the_list_subscription_is_not_read_as_a_community_subscription() { + let id = CommunityId::from_bytes([0x42; 32]); + + assert!(is_list_subscription(&list_subscription_id())); + assert!(community_of(&list_subscription_id()).is_none()); + assert_eq!(community_of(&subscription_id(&id)), Some(id)); + } } diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index 9c8d23b8..05d2aa71 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -132,6 +132,30 @@ impl CommunityList { (0..self.frags).all(|index| held.contains(&index)) } + pub fn joined(&self, entry: CommunityListEntry) -> CommunityList { + merge( + self.clone(), + CommunityList { + entries: vec![entry], + ..Default::default() + }, + ) + } + + pub fn tombstoned(&self, community_id: CommunityId, removed_at: u64) -> CommunityList { + merge( + self.clone(), + CommunityList { + tombstones: vec![Tombstone { + community_id, + removed_at, + extra: Extra::default(), + }], + ..Default::default() + }, + ) + } + pub fn fits(&self) -> Result<(), ListError> { if self.entries.len() > MAX_MEMBERSHIPS { return Err(ListError::TooManyMemberships(self.entries.len())); @@ -207,6 +231,7 @@ pub async fn build_list_event( signer: &S, list: &CommunityList, fragment: u64, + at_secs: u64, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, @@ -218,6 +243,7 @@ where EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) .tag(Tag::identifier(fragment.to_string())) + .custom_created_at(Timestamp::from_secs(at_secs)) .finalize_async(signer) .await .map_err(crypto_error) @@ -816,7 +842,7 @@ mod tests { ..Default::default() }; - let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds"); + let event = smol::block_on(build_list_event(&me, &mine, 1, AT_SECS)).expect("builds"); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); assert_eq!(fragment_index(&event).expect("a fragment index"), 1); assert_eq!( @@ -858,7 +884,7 @@ mod tests { .insert("read_key".to_owned(), serde_json::json!("aa".repeat(32))); let rebuilt = smol::block_on(parse_list_event( &me, - &smol::block_on(build_list_event(&me, &held, 0)).expect("builds"), + &smol::block_on(build_list_event(&me, &held, 0, AT_SECS)).expect("builds"), )) .expect("parses"); assert_eq!(rebuilt, held); @@ -878,7 +904,7 @@ mod tests { .collect(), ); assert!(matches!( - smol::block_on(build_list_event(&me, &crowded, 0)), + smol::block_on(build_list_event(&me, &crowded, 0, AT_SECS)), Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 )); @@ -1019,4 +1045,5 @@ mod tests { } const AT: u64 = 1_719_800_000_000; + const AT_SECS: u64 = AT / 1000; } diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 7f682fe5..1b92ddae 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -3,18 +3,20 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::LazyLock; use anyhow::{Result, anyhow}; +use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; -use crate::cord02::list::JoinMaterial; +use crate::cord02::list::{CommunityListEntry, JoinMaterial}; use crate::cord02::{ ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH, }; use crate::cord03::{self, ChatRumor, plane_keys}; use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk}; +use crate::cord05::ChannelGrant; use crate::derive::control_signer_group_key; -use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32}; +use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32}; static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); @@ -337,6 +339,40 @@ impl CommunityState { } } +pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry { + let material = JoinMaterial { + community_id: state.id, + owner: state.owner, + owner_salt: HEXLOWER.encode(&state.owner_salt), + community_root: HEXLOWER.encode(&state.community_root), + root_epoch: state.root_epoch, + control_pk: state.control_pks.get(&state.root_epoch.0).copied(), + control_root: state.control_root.map(|root| HEXLOWER.encode(&root)), + channels: state + .channels + .iter() + .map(|channel| ChannelGrant { + id: channel.id, + key: channel.key.map(|key| HEXLOWER.encode(&key)), + epoch: channel.epoch, + name: channel.name.clone(), + extra: Extra::default(), + }) + .collect(), + relays: state.relays.iter().map(RelayUrl::to_string).collect(), + name: name.to_owned(), + extra: Extra::default(), + }; + + CommunityListEntry { + community_id: state.id, + seed: material.clone(), + current: material, + added_at: state.added_at_ms, + extra: Extra::default(), + } +} + fn state_identifier(id: &CommunityId) -> String { format!("{STATE_PREFIX}{}", id.to_hex()) } diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md index 1bc74d0f..8dcc6646 100644 --- a/docs/concord-community-discovery-plan.md +++ b/docs/concord-community-discovery-plan.md @@ -84,19 +84,18 @@ Two consequences for coop: | # | Spec | coop today | | --- | --- | --- | -| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) | -| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` | -| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` | -| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present | +| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** | +| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)` — **fixed in Phase A** | +| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key` — **fixed in Phase A** | +| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** | | 5 | fetch from relays | local database only — **fixed in Phase C** | -| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` | -| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs | -| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field | +| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis` — **fixed in Phase B** | +| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** | +| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed | -Divergences 1–4 meant that even if the fetch existed, coop could neither read -what accordion wrote nor write something accordion could read. **Phases A, B and -C are done**, so 1–6 are resolved; 7 and 8 remain (8 only in that private planes -are still not subscribed). +Divergences 1–7 are resolved. 8 remains, in the narrow sense that `planes()` +still skips private channels rather than deriving their addresses from the +granted key. ## Plan @@ -140,9 +139,9 @@ example has five such values, so a strict decoder rejects the worked example; like the other two, so it belongs here rather than in Phase D. `parse_list_event` validates the `d` tag but returns just the `CommunityList`; -`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until -Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has -no membership limit, and Phase D's fragmentation is what removes the cap. +`fragment_index(event)` reads the index, which kept `sync.rs` untouched until +Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no +membership limit, and removing the cap needs write-time fragmentation. ### Phase B — materialize a community from join material (pure) — DONE @@ -221,27 +220,53 @@ needs no explicit save. The subscription is set up with `ReqTarget::auto` rather than a hand-built NIP-65 relay map, because gossip already resolves the author's write relays and connects them on demand. -Tests (no network): a fragment in the database with **no** state document -materializes a community and writes one; a tombstone at `u64::MAX` drops a held -membership; a two-fragment List with only fragment 0 delivered still yields its -membership; a held membership the List never mentions is kept alongside the -discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control -planes while taking the List's keys; and the `concord/list` id is not read as a -community subscription. Fragment events are built with `build_list_event` from a -§8 JSON payload, so the test exercises the real decrypt-and-merge path without a -relay. +Tests (no network, in `crates/community/src/sync.rs`): a membership the List +carries materializes a community even though no state document was ever written +for it, and discovery writes the document so the next load is warm; a held +membership the List never mentions is kept alongside the one it does; a tombstone +outranks a held membership and drops it; and the `concord/list` id is not read as +a community subscription. Fragment events are built with `store::list_entry` + +`CommunityList::joined` + `build_list_event` and saved straight into a memory +database, so the tests exercise the real seal/parse/merge path without a relay. -### Phase D — publish +### Phase D — publish — DONE -`crates/community/src/sync.rs`, `crates/concord/src/store.rs` +`crates/community/src/sync.rs`, `crates/concord/src/store.rs`, +`crates/concord/src/cords/cord02/list.rs` -1. `create` appends to the List and publishes the fragment read-modify-write per - §8, targeting the metadata's relays. -2. `create` publishes the genesis wraps to those relays. Today it only - `client.database().save_event(wrap)`s, so a created community is invisible to - every other account. -3. Leave uses a tombstone; a repack requires the complete List and is a - non-goal until memberships outgrow one fragment. +1. `create` mints the genesis, folds it into a state, and saves that state locally + as before, then announces the community: the genesis wraps to its relay set, + and the membership to the account's own List. Both publishes are best-effort — + a relay that is down is a warning, not a failed create. +2. The List write is a read-modify-write over the copy already held (§8). `create` + reads the newest held fragment, unions its own entry in with + `CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves + it locally as a side effect of `send_event`, before any relay is resolved, so + the fragment survives a relay that is down and no explicit database write is + needed. +3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable + relay can never quietly keep the copy the write meant to replace. + +**As built, deviating from the sketch above.** Three decisions the sketch did not +cover: + +- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the + community's metadata relays. The List is the member's own document, and it is + the same relay set `subscribe_list` resolves for its `author` filter — the two + halves must agree or a write can land where nothing reads. The genesis wraps, + which belong to the community and not the member, do go to the metadata relays. +- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`' + `extra` field is crate-private, so the community crate cannot build one; `name` + is passed in because the state does not carry it — the name lives in the Control + fold, and a created community has it in the metadata. +- A List that already spans more than one fragment is **left alone**: placing a + new membership needs a repack (which fragment does it belong in?), and §8 allows + a repack only against the complete List. `load` keeps a membership the List + never mentions, so the community is still tracked locally; the remote write is + deferred with a warning rather than performed wrongly. + +Tests: `create` records a membership the List round-trips, and a second create +unions into the same document instead of replacing it. ### Phase E — verify live @@ -267,9 +292,11 @@ rows in the sidebar. This is the first time the path can be exercised at all. `list.rs` and never case-folds. - **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its only bound is the 65,536-byte *encoded event*. `fits()` still measures the - NIP-44 plaintext, which understates that by roughly a third, so the count cap is - kept as a conservative stopgap until Phase D measures the built event and - fragments on write. + NIP-44 plaintext, which understates that by roughly a third. Phase D kept the + count cap and added a guard: a List that already spans more than one fragment is + not appended to, because placing a new membership needs a repack. So a member + with more than one fragment gets no remote write until fragmentation lands; the + community stays local and visible. - **Relay selection is the difference between finding the account's List and not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the filter's author to their NIP-65 write relays and connects them. A List @@ -282,6 +309,28 @@ rows in the sidebar. This is the first time the path can be exercised at all. both accordion and coop has both clients writing the List. §8's read-modify-write is what keeps that from losing memberships — it is not optional. +- **A create racing the first list sync can publish over an unseen List.** + `record_membership` unions into what the local database holds, and on a fresh + sign-in that is empty until the `concord/list` subscription has delivered. A + create in that window writes a one-entry fragment 0, and an addressable relay + then replaces the account's fuller List with it. The window is the ordinary + sign-in-to-create interval, so it is small but not zero. The honest fix is to + treat the List write as part of the sync loop — republish `list ∪ local + memberships` whenever the subscription settles — rather than doing it inside + `create`; an EOSE flag is not enough on its own, because an account with no + NIP-65 relays never reaches EOSE and would then never write at all. - **`store::save_state` signs with a per-process random key.** Harmless while it stays local, but it means the state document can never be published or compared; if a future phase wants it on the wire, it needs the account signer. +- **The deployed reference client still writes the retired kind `13302`.** The + spec this plan implements (`concord-protocol/concord` `main`) moved the List to + `33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that + accordion.chat builds against still declares `13302`, single-event, capped at 50 + memberships, at its head of **2026-08-05**; accordion's pin predates even that + (`0.0.0-concord-20260804145327`). So an account whose memberships were written + by that build stores them under a kind coop deliberately does not read, and will + show an empty sidebar until the client is updated to the fragmented kind. This + is not a bug in the discovery path — Phases C and D are correct against the + current spec — but it is the first thing to check if a live sign-in still shows + nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the + reference client moves. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index a04cb89e..5fecfbd8 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -79,6 +79,12 @@ save_state(&client, &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. +Creating is not finished until the membership is announced. The two writes are +independent and both best-effort: the genesis wraps go to the community's +relays, and the membership goes to the account's own Community List (below), so a +new device — or another client — can find the community without an invite. +`crates/community`'s `sync::create` performs both. + ## Joining An invite link resolves to a bundle: @@ -413,15 +419,29 @@ A member's own memberships, synced across their devices: ```rust use concord::cord02::list; -let material = list::join_material(&invite, staff.then_some(&control_root)); -let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag -mine = list::merge(mine, list::CommunityList { - entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], - ..Default::default() // frags: 1 -}); -let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0 +let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material +let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag +let mine = held.joined(entry); // community_id-keyed union +let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0 +client.send_event(&event).to_nip65().await?; // account's own write relays ``` +The publish needs no separate database write: `send_event` persists the event +locally *before* it resolves targets, so the fragment is available to the +`concord/list` read path even if every relay is unreachable. + +`join_material(&invite, control_root)` makes the §8 material from a CORD-05 +invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is +what a write path uses after a create, join or rename. `joined` and `tombstoned` +are the two mutations: both are `community_id`-keyed unions, so neither an append +nor a leave can lose a membership the other writer has. + +The entry is signed by the member's real key and sealed to that same key +(`seal_to_self`), so only the member's devices read it — a stranger's +`parse_list_event` fails rather than returning a partial list. Publishing goes to +the member's **NIP-65 write relays**, the same set the `concord/list` +subscription resolves for its `author` filter. + Kind `33302` is **addressable and fragmented**: one event per fragment, its `d` tag the fragment index in decimal. `frags` in the payload declares how many the List has, and `is_complete(held_indices)` answers whether the client has a @@ -452,8 +472,11 @@ and its wire form differ: strictly newer join outruns it. `fits()` is the write gate: 50 memberships and the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired single-event design — §8 has **no membership limit**, its only bound is the -65,536-byte encoded event, and the real fix is to start a new fragment on write -(see `docs/concord-community-discovery-plan.md`, Phase D). +65,536-byte encoded event, and the real fix is to start a new fragment on write. +Until that lands, an append onto a List that already spans more than one fragment +is refused rather than performed against a partial read, because placing a new +membership needs a repack. The community stays local (`load` keeps a membership +the List never mentions) and the write is deferred with a warning. Discovery is a **subscription, not a fetch**: subscribe with `Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the @@ -581,8 +604,10 @@ client.subscribe(filter).with_id(sub_id).await?; per state document, subscribes when a community's plane set changes, and re-folds on an inbound wrap. The sidebar observes the registry, logs `CommunityEvent::Error` through `log::error!`, and its "New community" row opens - a name prompt that calls `CommunityRegistry::create`. `create` still persists - the genesis locally without publishing it to the metadata's relays. Discovery + a name prompt that calls `CommunityRegistry::create`. `create` persists the + genesis locally, publishes the wraps to the community's relays, and records the + membership in the account's Community List — all best-effort, so a relay that is + down warns without losing the community. Discovery subscribes to the account's CORD-02 Community List (`33302`) under the `concord/list` subscription id and reads the fragments back out of `client.database()` — the SDK persists a relay's event before notifying, so the @@ -590,7 +615,8 @@ client.subscribe(filter).with_id(sub_id).await?; materializes a community from each live List entry (`from_join_material`) and keeps any state document the List does not mention, so a fresh install — or one signing in as an account that joined elsewhere — finds its communities. See - `docs/concord-community-discovery-plan.md`. + `docs/concord-community-discovery-plan.md` (including its note on the retired + `13302` the current reference client still writes). - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,