update publish community

This commit is contained in:
2026-09-19 09:27:41 +07:00
parent 9a391ca76d
commit 97e7539cea
5 changed files with 520 additions and 68 deletions
+330 -16
View File
@@ -92,14 +92,13 @@ pub struct Snapshot {
pub members: BTreeSet<PublicKey>, pub members: BTreeSet<PublicKey>,
} }
/// Mints a community owned by `signer` and persists it locally.
pub async fn create<S>( pub async fn create<S>(
client: &Client, client: &Client,
signer: &S, signer: &S,
metadata: &cord02::CommunityMetadata, metadata: &cord02::CommunityMetadata,
) -> Result<CommunityState> ) -> Result<CommunityState>
where where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{ {
let at_secs = Timestamp::now().as_secs(); let at_secs = Timestamp::now().as_secs();
let genesis = cord02::genesis(signer, metadata, at_secs).await?; let genesis = cord02::genesis(signer, metadata, at_secs).await?;
@@ -112,15 +111,108 @@ where
for wrap in &genesis.wraps { for wrap in &genesis.wraps {
editions.push(cord02::open_edition(wrap, &read, &address, true)?); 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))?; let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
store::save_state(client, &state).await?; 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) 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<S>(
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<Option<Timestamp>> {
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. /// The subscription id carrying the account's own Community List.
pub const LIST_SUBSCRIPTION: &str = "concord/list"; pub const LIST_SUBSCRIPTION: &str = "concord/list";
@@ -249,12 +341,8 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
held held
} }
/// Every fragment of the account's list in the local database, merged. /// The newest held copy of each fragment, keyed by its `d` index.
async fn load_list( async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result<BTreeMap<u64, Event>> {
client: &Client,
signer: &UniversalSigner,
self_pk: PublicKey,
) -> Result<Option<CommunityList>> {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::Custom(KIND_COMMUNITY_LIST)) .kind(Kind::Custom(KIND_COMMUNITY_LIST))
.author(self_pk); .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<S>(
client: &Client,
signer: &S,
self_pk: PublicKey,
) -> Result<Option<CommunityList>>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let mut merged: Option<CommunityList> = None; let mut merged: Option<CommunityList> = 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 { match cord02::list::parse_list_event(signer, &event).await {
Ok(list) => { Ok(list) => {
merged = Some(match merged { merged = Some(match merged {
@@ -464,22 +564,55 @@ mod tests {
.any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) .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 filter = subscription_filter(&planes);
let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect(); let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
assert_eq!(filter.authors, Some(addresses)); assert_eq!(filter.authors, Some(addresses));
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); 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 { cord02::CommunityMetadata {
name: name.to_owned(), name: name.to_owned(),
relays: vec![relay.to_owned()],
..cord02::CommunityMetadata::default() ..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<S>(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 /// What `CommunityRegistry` needs from a created community: a state document
/// `load` finds, a control plane the subscription filter actually addresses, /// `load` finds, a control plane the subscription filter actually addresses,
/// and a fold that survives an inbound control edit. /// and a fold that survives an inbound control edit.
@@ -490,7 +623,7 @@ mod tests {
let keys = Keys::generate(); let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone()); let signer = UniversalSigner::new(keys.clone());
let created = create(&client, &signer, &metadata("coop", "wss://relay.example")) let created = create(&client, &signer, &metadata("coop"))
.await .await
.expect("creates"); .expect("creates");
@@ -547,7 +680,7 @@ mod tests {
.set_community_metadata( .set_community_metadata(
&keys, &keys,
&created.id, &created.id,
&metadata("coop two", "wss://relay.example"), &metadata("coop two"),
Some(community_head), Some(community_head),
None, None,
Timestamp::now().as_secs() + 1, 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<CommunityId> = 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));
}
} }
+30 -3
View File
@@ -132,6 +132,30 @@ impl CommunityList {
(0..self.frags).all(|index| held.contains(&index)) (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> { pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS { if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len())); return Err(ListError::TooManyMemberships(self.entries.len()));
@@ -207,6 +231,7 @@ pub async fn build_list_event<S>(
signer: &S, signer: &S,
list: &CommunityList, list: &CommunityList,
fragment: u64, fragment: u64,
at_secs: u64,
) -> Result<Event, ListError> ) -> Result<Event, ListError>
where where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
@@ -218,6 +243,7 @@ where
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.tag(Tag::identifier(fragment.to_string())) .tag(Tag::identifier(fragment.to_string()))
.custom_created_at(Timestamp::from_secs(at_secs))
.finalize_async(signer) .finalize_async(signer)
.await .await
.map_err(crypto_error) .map_err(crypto_error)
@@ -816,7 +842,7 @@ mod tests {
..Default::default() ..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!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(fragment_index(&event).expect("a fragment index"), 1); assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
assert_eq!( assert_eq!(
@@ -858,7 +884,7 @@ mod tests {
.insert("read_key".to_owned(), serde_json::json!("aa".repeat(32))); .insert("read_key".to_owned(), serde_json::json!("aa".repeat(32)));
let rebuilt = smol::block_on(parse_list_event( let rebuilt = smol::block_on(parse_list_event(
&me, &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"); .expect("parses");
assert_eq!(rebuilt, held); assert_eq!(rebuilt, held);
@@ -878,7 +904,7 @@ mod tests {
.collect(), .collect(),
); );
assert!(matches!( 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 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: u64 = 1_719_800_000_000;
const AT_SECS: u64 = AT / 1000;
} }
+38 -2
View File
@@ -3,18 +3,20 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock; use std::sync::LazyLock;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::list::JoinMaterial; use crate::cord02::list::{CommunityListEntry, JoinMaterial};
use crate::cord02::{ use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH, ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
}; };
use crate::cord03::{self, ChatRumor, plane_keys}; use crate::cord03::{self, ChatRumor, plane_keys};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk}; use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::cord05::ChannelGrant;
use crate::derive::control_signer_group_key; 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<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = 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 { fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex()) format!("{STATE_PREFIX}{}", id.to_hex())
} }
+84 -35
View File
@@ -84,19 +84,18 @@ Two consequences for coop:
| # | Spec | coop today | | # | Spec | coop today |
| --- | --- | --- | | --- | --- | --- |
| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) | | 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 | no `frags`, single event, `d` unused, `load_list` `.limit(1)` | | 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 | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` | | 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` | both snapshots always serialized verbatim; `community_id` always present | | 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** | | 5 | fetch from relays | local database only — **fixed in Phase C** |
| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` | | 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 | | 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 no key field | | 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed |
Divergences 14 meant that even if the fetch existed, coop could neither read Divergences 17 are resolved. 8 remains, in the narrow sense that `planes()`
what accordion wrote nor write something accordion could read. **Phases A, B and still skips private channels rather than deriving their addresses from the
C are done**, so 16 are resolved; 7 and 8 remain (8 only in that private planes granted key.
are still not subscribed).
## Plan ## 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. 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`; `parse_list_event` validates the `d` tag but returns just the `CommunityList`;
`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until `fragment_index(event)` reads the index, which kept `sync.rs` untouched until
Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no
no membership limit, and Phase D's fragmentation is what removes the cap. membership limit, and removing the cap needs write-time fragmentation.
### Phase B — materialize a community from join material (pure) — DONE ### 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 than a hand-built NIP-65 relay map, because gossip already resolves the author's
write relays and connects them on demand. write relays and connects them on demand.
Tests (no network): a fragment in the database with **no** state document Tests (no network, in `crates/community/src/sync.rs`): a membership the List
materializes a community and writes one; a tombstone at `u64::MAX` drops a held carries materializes a community even though no state document was ever written
membership; a two-fragment List with only fragment 0 delivered still yields its for it, and discovery writes the document so the next load is warm; a held
membership; a held membership the List never mentions is kept alongside the membership the List never mentions is kept alongside the one it does; a tombstone
discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control outranks a held membership and drops it; and the `concord/list` id is not read as
planes while taking the List's keys; and the `concord/list` id is not read as a a community subscription. Fragment events are built with `store::list_entry` +
community subscription. Fragment events are built with `build_list_event` from a `CommunityList::joined` + `build_list_event` and saved straight into a memory
§8 JSON payload, so the test exercises the real decrypt-and-merge path without a database, so the tests exercise the real seal/parse/merge path without a relay.
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 1. `create` mints the genesis, folds it into a state, and saves that state locally
§8, targeting the metadata's relays. as before, then announces the community: the genesis wraps to its relay set,
2. `create` publishes the genesis wraps to those relays. Today it only and the membership to the account's own List. Both publishes are best-effort —
`client.database().save_event(wrap)`s, so a created community is invisible to a relay that is down is a warning, not a failed create.
every other account. 2. The List write is a read-modify-write over the copy already held (§8). `create`
3. Leave uses a tombstone; a repack requires the complete List and is a reads the newest held fragment, unions its own entry in with
non-goal until memberships outgrow one fragment. `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 ### 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. `list.rs` and never case-folds.
- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its - **`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 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 NIP-44 plaintext, which understates that by roughly a third. Phase D kept the
kept as a conservative stopgap until Phase D measures the built event and count cap and added a guard: a List that already spans more than one fragment is
fragments on write. 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 - **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 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 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 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 read-modify-write is what keeps that from losing memberships — it is not
optional. 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 - **`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 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. 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.
+38 -12
View File
@@ -79,6 +79,12 @@ save_state(&client, &state).await?;
Put the community's relay list into `state.relays` and add those relays to the 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. 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 ## Joining
An invite link resolves to a bundle: An invite link resolves to a bundle:
@@ -413,15 +419,29 @@ A member's own memberships, synced across their devices:
```rust ```rust
use concord::cord02::list; use concord::cord02::list;
let material = list::join_material(&invite, staff.then_some(&control_root)); let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material
let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
mine = list::merge(mine, list::CommunityList { let mine = held.joined(entry); // community_id-keyed union
entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
..Default::default() // frags: 1 client.send_event(&event).to_nip65().await?; // account's own write relays
});
let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0
``` ```
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` 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 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 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 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 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 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 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). 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 Discovery is a **subscription, not a fetch**: subscribe with
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the `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 per state document, subscribes when a community's plane set changes, and
re-folds on an inbound wrap. The sidebar observes the registry, logs re-folds on an inbound wrap. The sidebar observes the registry, logs
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens `CommunityEvent::Error` through `log::error!`, and its "New community" row opens
a name prompt that calls `CommunityRegistry::create`. `create` still persists a name prompt that calls `CommunityRegistry::create`. `create` persists the
the genesis locally without publishing it to the metadata's relays. Discovery 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 subscribes to the account's CORD-02 Community List (`33302`) under the
`concord/list` subscription id and reads the fragments back out of `concord/list` subscription id and reads the fragments back out of
`client.database()` — the SDK persists a relay's event before notifying, so the `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 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 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 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`, - **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,