From 1cf5ecba5108f830877b1a7f3c20433925e07195 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:39:25 +0700 Subject: [PATCH] refactor --- Cargo.lock | 1 + Cargo.toml | 1 + crates/concord/Cargo.toml | 1 + crates/concord/src/cords/cord02/list.rs | 655 ++++++++++++++++++++--- crates/concord/src/cords/cord05.rs | 2 +- crates/concord/src/utils/base64url.rs | 34 ++ crates/concord/src/utils/mod.rs | 35 ++ docs/concord-community-discovery-plan.md | 241 +++++++++ docs/concord-usage.md | 52 +- 9 files changed, 951 insertions(+), 71 deletions(-) create mode 100644 crates/concord/src/utils/base64url.rs create mode 100644 docs/concord-community-discovery-plan.md diff --git a/Cargo.lock b/Cargo.lock index f75466b7..3704641d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,6 +1314,7 @@ name = "concord" version = "1.0.2" dependencies = [ "anyhow", + "base64 0.22.1", "chacha20 0.9.1", "data-encoding", "hkdf", diff --git a/Cargo.toml b/Cargo.toml index 8ced0833..c7968032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni # Crypto (NIP-17 encrypted file messages) aes-gcm = "0.10" +base64 = "0.22" sha2 = "0.10" data-encoding = "2" hkdf = "0.12" diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml index 5546f66d..59c2a612 100644 --- a/crates/concord/Cargo.toml +++ b/crates/concord/Cargo.toml @@ -13,6 +13,7 @@ sha2.workspace = true chacha20.workspace = true hmac.workspace = true data-encoding.workspace = true +base64.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index 2878f8e9..9c8d23b8 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -1,15 +1,17 @@ -use std::collections::BTreeMap; use std::collections::btree_map::Entry; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; use crate::cord05::{ChannelGrant, CommunityInvite}; -use crate::{CommunityId, Epoch, Extra}; +use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union}; +use crate::{ChannelId, CommunityId, Epoch, Extra}; -pub const KIND_COMMUNITY_LIST: u16 = 13302; +pub const KIND_COMMUNITY_LIST: u16 = 33302; pub const MAX_MEMBERSHIPS: usize = 50; #[derive(Debug)] @@ -17,6 +19,8 @@ pub enum ListError { Kind(u16), Crypto(String), Json(String), + Encoding(String), + Fragment(String), TooManyMemberships(usize), Oversize(usize), } @@ -27,6 +31,8 @@ impl fmt::Display for ListError { ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"), ListError::Crypto(error) => write!(f, "crypto: {error}"), ListError::Json(error) => write!(f, "json: {error}"), + ListError::Encoding(error) => write!(f, "encoding: {error}"), + ListError::Fragment(error) => write!(f, "fragment: {error}"), ListError::TooManyMemberships(count) => { write!( f, @@ -48,55 +54,58 @@ impl From for ListError { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct JoinMaterial { pub community_id: CommunityId, pub owner: PublicKey, pub owner_salt: String, pub community_root: String, pub root_epoch: Epoch, - #[serde(default, skip_serializing_if = "Option::is_none")] pub control_pk: Option, /// Present only when the holder is staff. - #[serde(default, skip_serializing_if = "Option::is_none")] pub control_root: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub channels: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub relays: Vec, pub name: String, - #[serde(flatten)] pub extra: Extra, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct CommunityListEntry { pub community_id: CommunityId, pub seed: JoinMaterial, pub current: JoinMaterial, pub added_at: u64, - #[serde(flatten)] pub extra: Extra, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct Tombstone { pub community_id: CommunityId, pub removed_at: u64, - #[serde(flatten)] pub extra: Extra, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct CommunityList { - #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// How many fragments this List has. Every fragment declares it. + pub frags: u64, pub entries: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tombstones: Vec, - #[serde(flatten)] pub extra: Extra, } +impl Default for CommunityList { + fn default() -> Self { + Self { + frags: 1, + entries: Vec::new(), + tombstones: Vec::new(), + extra: Extra::default(), + } + } +} + impl CommunityList { pub fn is_live(&self, community_id: &CommunityId) -> bool { let added = self @@ -115,6 +124,14 @@ impl CommunityList { } } + pub fn is_complete(&self, held: I) -> bool + where + I: IntoIterator, + { + let held: BTreeSet = held.into_iter().collect(); + (0..self.frags).all(|index| held.contains(&index)) + } + pub fn fits(&self) -> Result<(), ListError> { if self.entries.len() > MAX_MEMBERSHIPS { return Err(ListError::TooManyMemberships(self.entries.len())); @@ -138,7 +155,7 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) community_root: invite.community_root.clone(), root_epoch: invite.root_epoch, control_pk: invite.control_pk, - control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)), + control_root: control_root.map(|key| HEXLOWER.encode(key)), channels: invite.channels.clone(), relays: invite.relays.clone(), name: invite.name.clone(), @@ -149,7 +166,9 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { let mut entries: BTreeMap = BTreeMap::new(); - for entry in held.entries.into_iter().chain(incoming.entries) { + for mut entry in held.entries.into_iter().chain(incoming.entries) { + normalize(&mut entry); + match entries.entry(entry.community_id) { Entry::Vacant(slot) => { slot.insert(entry); @@ -177,28 +196,34 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { union(&mut extra, incoming.extra); CommunityList { + frags: held.frags.max(incoming.frags), entries: entries.into_values().collect(), tombstones: tombstones.into_values().collect(), extra, } } -pub async fn build_list_event(keys: &S, list: &CommunityList) -> Result +pub async fn build_list_event( + signer: &S, + list: &CommunityList, + fragment: u64, +) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { list.fits()?; let json = serde_json::to_string(list).map_err(json_error)?; - let content = cord01::seal_to_self(keys, &json).await?; + let content = cord01::seal_to_self(signer, &json).await?; EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) - .finalize_async(keys) + .tag(Tag::identifier(fragment.to_string())) + .finalize_async(signer) .await .map_err(crypto_error) } -pub async fn parse_list_event(keys: &S, event: &Event) -> Result +pub async fn parse_list_event(signer: &S, event: &Event) -> Result where S: AsyncGetPublicKey + AsyncNip44 + ?Sized, { @@ -206,11 +231,326 @@ where return Err(ListError::Kind(event.kind.as_u16())); } - let json = cord01::open_to_self(keys, &event.content).await?; + fragment_index(event)?; + + let json = cord01::open_to_self(signer, &event.content).await?; serde_json::from_str(&json).map_err(json_error) } +pub fn fragment_index(event: &Event) -> Result { + let value = event + .tags + .identifier() + .ok_or_else(|| ListError::Fragment("missing d tag".to_owned()))?; + + value + .parse() + .map_err(|_| ListError::Fragment(format!("d tag is not a fragment index: {value}"))) +} + +fn decode_base64(value: &str, field: &str) -> Result<[u8; 32], ListError> { + base64url::decode_32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +fn decode_community_id(value: &str) -> Result { + Ok(CommunityId::from_bytes(decode_base64( + value, + "community_id", + )?)) +} + +fn decode_public_key(value: &str, field: &str) -> Result { + PublicKey::from_slice(&decode_base64(value, field)?) + .map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +fn encode_hex_32(value: &str, field: &str) -> Result { + hex32_to_base64(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +fn decode_hex_32_base64(value: &str, field: &str) -> Result { + base64_to_hex32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireList { + #[serde(default = "one_fragment")] + frags: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tombstones: Vec, + #[serde(flatten)] + extra: Extra, +} + +fn one_fragment() -> u64 { + 1 +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireEntry { + community_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + seed: Option, + current: WireSnapshot, + added_at: u64, + #[serde(flatten)] + extra: Extra, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireTombstone { + community_id: String, + removed_at: u64, + #[serde(flatten)] + extra: Extra, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireSnapshot { + owner: String, + owner_salt: String, + community_root: String, + root_epoch: Epoch, + #[serde(default, skip_serializing_if = "Option::is_none")] + control_pk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + control_root: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + channels: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + relays: Vec, + name: String, + #[serde(flatten)] + extra: Extra, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireChannel { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + key: Option, + epoch: Epoch, + #[serde(default)] + name: String, + #[serde(flatten)] + extra: Extra, +} + +impl WireList { + fn encode(list: &CommunityList) -> Result { + // §8: a retired entry is not written — the tombstone alone carries the + // state, since a membership is live only while an entry outranks its + // removal. The entry stays in the in-memory document. + let mut entries = Vec::with_capacity(list.entries.len()); + for entry in &list.entries { + if list.is_live(&entry.community_id) { + entries.push(WireEntry::encode(entry)?); + } + } + + let mut tombstones = Vec::with_capacity(list.tombstones.len()); + for tombstone in &list.tombstones { + tombstones.push(WireTombstone::encode(tombstone)?); + } + + Ok(Self { + frags: list.frags, + entries, + tombstones, + extra: list.extra.clone(), + }) + } + + fn decode(self) -> Result { + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in self.entries { + entries.push(entry.decode()?); + } + + let mut tombstones = Vec::with_capacity(self.tombstones.len()); + for tombstone in self.tombstones { + tombstones.push(tombstone.decode()?); + } + + Ok(CommunityList { + frags: self.frags.max(1), + entries, + tombstones, + extra: self.extra, + }) + } +} + +impl WireEntry { + fn encode(entry: &CommunityListEntry) -> Result { + let current = WireSnapshot::encode(&entry.current)?; + + let mut seed = entry.seed.clone(); + normalize_snapshot(&mut seed, &entry.current); + + let seed = if seed == entry.current { + None + } else { + Some(WireSnapshot::encode(&seed)?) + }; + + Ok(Self { + community_id: base64url::encode(entry.community_id.as_bytes()), + seed, + current, + added_at: entry.added_at, + extra: entry.extra.clone(), + }) + } + + fn decode(self) -> Result { + let community_id = decode_community_id(&self.community_id)?; + let current = self.current.decode(community_id)?; + let seed = match self.seed { + Some(seed) => seed.decode(community_id)?, + None => current.clone(), + }; + + let mut entry = CommunityListEntry { + community_id, + seed, + current, + added_at: self.added_at, + extra: self.extra, + }; + normalize(&mut entry); + + Ok(entry) + } +} + +impl WireTombstone { + fn encode(tombstone: &Tombstone) -> Result { + Ok(Self { + community_id: base64url::encode(tombstone.community_id.as_bytes()), + removed_at: tombstone.removed_at, + extra: tombstone.extra.clone(), + }) + } + + fn decode(self) -> Result { + Ok(Tombstone { + community_id: decode_community_id(&self.community_id)?, + removed_at: self.removed_at, + extra: self.extra, + }) + } +} + +impl WireSnapshot { + fn encode(material: &JoinMaterial) -> Result { + let mut channels = Vec::with_capacity(material.channels.len()); + for channel in &material.channels { + channels.push(WireChannel::encode(channel)?); + } + + Ok(Self { + owner: base64url::encode(&material.owner.to_bytes()), + owner_salt: encode_hex_32(&material.owner_salt, "owner_salt")?, + community_root: encode_hex_32(&material.community_root, "community_root")?, + root_epoch: material.root_epoch, + control_pk: material + .control_pk + .map(|key| base64url::encode(&key.to_bytes())), + control_root: match &material.control_root { + Some(root) => Some(encode_hex_32(root, "control_root")?), + None => None, + }, + channels, + relays: material.relays.clone(), + name: material.name.clone(), + extra: material.extra.clone(), + }) + } + + fn decode(self, community_id: CommunityId) -> Result { + let mut channels = Vec::with_capacity(self.channels.len()); + for channel in self.channels { + channels.push(channel.decode()?); + } + + Ok(JoinMaterial { + community_id, + owner: decode_public_key(&self.owner, "owner")?, + owner_salt: decode_hex_32_base64(&self.owner_salt, "owner_salt")?, + community_root: decode_hex_32_base64(&self.community_root, "community_root")?, + root_epoch: self.root_epoch, + control_pk: match self.control_pk { + Some(value) => Some(decode_public_key(&value, "control_pk")?), + None => None, + }, + control_root: match self.control_root { + Some(value) => Some(decode_hex_32_base64(&value, "control_root")?), + None => None, + }, + channels, + relays: self.relays, + name: self.name, + extra: self.extra, + }) + } +} + +impl WireChannel { + fn encode(grant: &ChannelGrant) -> Result { + Ok(Self { + id: base64url::encode(grant.id.as_bytes()), + key: match &grant.key { + Some(key) => Some(encode_hex_32(key, "channel key")?), + None => None, + }, + epoch: grant.epoch, + name: grant.name.clone(), + extra: grant.extra.clone(), + }) + } + + fn decode(self) -> Result { + Ok(ChannelGrant { + id: ChannelId::from_bytes(decode_base64(&self.id, "channel id")?), + key: match self.key { + Some(value) => Some(decode_hex_32_base64(&value, "channel key")?), + None => None, + }, + epoch: self.epoch, + name: self.name, + extra: self.extra, + }) + } +} + +impl Serialize for CommunityList { + fn serialize(&self, serializer: S) -> Result { + WireList::encode(self) + .map_err(serde::ser::Error::custom)? + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for CommunityList { + fn deserialize>(deserializer: D) -> Result { + WireList::deserialize(deserializer)? + .decode() + .map_err(serde::de::Error::custom) + } +} + +impl Serialize for JoinMaterial { + fn serialize(&self, serializer: S) -> Result { + WireSnapshot::encode(self) + .map_err(serde::ser::Error::custom)? + .serialize(serializer) + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum Snapshot { Seed, @@ -222,6 +562,7 @@ fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) { held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone(); held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone(); union(&mut held.extra, incoming.extra); + normalize(held); } fn pick<'a>( @@ -245,21 +586,26 @@ fn pick<'a>( held } -pub(crate) fn union(into: &mut Extra, other: Extra) { - for (key, value) in other { - let replace = match into.get(&key) { - Some(existing) => canonical(&value) < canonical(existing), - None => true, - }; - - if replace { - into.insert(key, value); - } - } +fn normalize(entry: &mut CommunityListEntry) { + entry.seed.community_id = entry.community_id; + entry.current.community_id = entry.community_id; + normalize_snapshot(&mut entry.seed, &entry.current); } -pub(crate) fn canonical(value: &T) -> String { - serde_json::to_string(value).unwrap_or_default() +fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) { + seed.community_id = current.community_id; + seed.name = current.name.clone(); + seed.relays = current.relays.clone(); + + for seed_channel in &mut seed.channels { + if let Some(current_channel) = current + .channels + .iter() + .find(|channel| channel.id == seed_channel.id) + { + seed_channel.name = current_channel.name.clone(); + } + } } fn json_error(error: serde_json::Error) -> ListError { @@ -365,7 +711,7 @@ mod tests { } #[test] - fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() { + fn a_tombstone_is_terminal_and_the_entry_it_retires_is_never_written() { let owner = Keys::generate().public_key(); let joined = entry( id(0x11), @@ -385,22 +731,66 @@ mod tests { // A stale device re-merging the entry cannot resurrect it. assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11))); - // A re-join genuinely newer than the removal does. - let rejoined = list(vec![entry( - id(0x11), - material(id(0x11), owner, "Room", 0), - material(id(0x11), owner, "Room", 0), - 7_000, - )]); - let live = merge(left, rejoined); - assert!(live.is_live(&id(0x11))); + // The tombstone alone is written, so the entry's key material leaves the wire. + let written = serde_json::to_string(&left).expect("writes"); + assert!( + !written.contains("\"entries\""), + "a retired entry is not written" + ); + let reparsed: CommunityList = serde_json::from_str(&written).expect("parses"); + assert_eq!(reparsed.tombstones.len(), 1); + assert!(!reparsed.is_live(&id(0x11))); + + // A re-join genuinely newer than the removal does, and is written again. + let rejoined = merge( + reparsed, + list(vec![entry( + id(0x11), + material(id(0x11), owner, "Room", 0), + material(id(0x11), owner, "Room", 0), + 7_000, + )]), + ); + assert!(rejoined.is_live(&id(0x11))); + assert!( + serde_json::to_string(&rejoined) + .expect("writes") + .contains("\"entries\"") + ); // And the older removal is not re-applied on top of it. - assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11))); + assert!(merge(rejoined, removal(id(0x11), 6_000)).is_live(&id(0x11))); } #[test] - fn a_second_device_reconstructs_membership_from_13302() { + fn frags_disagreement_resolves_to_the_larger_value_and_completeness_is_by_index() { + let two = CommunityList { + frags: 2, + ..Default::default() + }; + let three = CommunityList { + frags: 3, + ..Default::default() + }; + + assert_eq!( + merge(two.clone(), three.clone()).frags, + 3, + "the larger fragment count wins" + ); + assert_eq!(merge(three.clone(), two).frags, 3); + + assert!(!three.is_complete([0, 1])); + assert!(three.is_complete([0, 1, 2])); + assert!(three.is_complete([2, 1, 0]), "order does not matter"); + assert!( + three.is_complete([0, 1, 2, 7]), + "indices at or above frags are out of range" + ); + } + + #[test] + fn a_second_device_reconstructs_membership_from_the_list() { let me = Keys::generate(); let owner = Keys::generate().public_key(); let mine = CommunityList { @@ -423,26 +813,32 @@ mod tests { removed_at: AT, extra: Extra::default(), }], - extra: Extra::default(), + ..Default::default() }; - let event = smol::block_on(build_list_event(&me, &mine)).expect("builds"); + let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds"); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); + assert_eq!(fragment_index(&event).expect("a fragment index"), 1); assert_eq!( smol::block_on(parse_list_event(&me, &event)).expect("parses"), mine ); - assert!( - !smol::block_on(parse_list_event(&me, &event)) - .expect("parses") - .is_live(&id(0x33)) - ); // Only the member's own keys open it, and an unreadable list is "no news". let stranger = Keys::generate(); assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err()); - // Unknown fields survive the round trip, so a republish cannot wipe them. + // A fragment with no `d` tag is not a fragment at all. + let untagged = EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), event.content.clone()) + .finalize(&me) + .expect("signs"); + assert!(matches!( + smol::block_on(parse_list_event(&me, &untagged)), + Err(ListError::Fragment(_)) + )); + + // Unknown fields survive the round trip, so a republish cannot wipe them + // — including on a channel, where a dropped field destroys key material. let mut held = mine.clone(); held.extra .insert("future".to_owned(), serde_json::json!({"deep": [1, 2]})); @@ -450,9 +846,19 @@ mod tests { .current .extra .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); + held.entries[0].current.channels = vec![ChannelGrant { + id: ChannelId::from_bytes([0x9c; 32]), + key: Some("55".repeat(32)), + epoch: Epoch(2), + name: "staff".to_owned(), + extra: Extra::default(), + }]; + held.entries[0].current.channels[0] + .extra + .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)).expect("builds"), + &smol::block_on(build_list_event(&me, &held, 0)).expect("builds"), )) .expect("parses"); assert_eq!(rebuilt, held); @@ -472,18 +878,145 @@ mod tests { .collect(), ); assert!(matches!( - smol::block_on(build_list_event(&me, &crowded)), + smol::block_on(build_list_event(&me, &crowded, 0)), Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 )); let oversized = list(vec![entry( id(0x11), - material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0), material(id(0x11), owner, "Room", 0), + material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0), AT, )]); assert!(matches!(oversized.fits(), Err(ListError::Oversize(_)))); } + /// The worked example in `examples.md` §6.2, verbatim. Five of its base64url + /// values leave non-zero trailing bits, so a strict decoder rejects them. + const EXAMPLE: &str = r#"{ + "frags": 2, + "entries": [ + { + "community_id": "PxpVK3nQ7sB1yTfWm4dLxZ0aRcE9uHgKjNvOpQrStUv", + "current": { + "owner": "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9", + "owner_salt": "qhEwR9tYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI0oP", + "community_root": "d70Xa1QwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U", + "root_epoch": 3, + "control_pk": "DU8vB4nM6qW1eR3tY5uI7oP9aS0dF2gH4jK6lZ8xC0v", + "channels": [ + { "id": "Ch1dQwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U0i", + "key": "K3yAsDfGhJkLzXcVbNm1Qw2Er3Ty4Ui5Op6As7Df8Gh", + "epoch": 2, "name": "staff" } + ], + "relays": ["wss://relay.example.com"], + "name": "Example Community" + }, + "added_at": 1719800000000 + } + ], + "tombstones": [ + { "community_id": "u9RfLmWx3PqZtYvBnKjHgFdSaQwErTyUiOp2C4E6G8I", "removed_at": 1722400000000 } + ] + }"#; + + #[test] + fn the_spec_example_parses_and_the_writer_canonicalizes_it() { + let parsed: CommunityList = serde_json::from_str(EXAMPLE).expect("the spec example parses"); + + assert_eq!(parsed.frags, 2); + assert_eq!(parsed.tombstones.len(), 1); + + let entry = parsed.entries.first().expect("one membership"); + assert_eq!(entry.current.root_epoch, Epoch(3)); + assert_eq!(entry.current.name, "Example Community"); + assert_eq!(entry.current.relays, ["wss://relay.example.com"]); + assert!( + entry.current.control_root.is_none(), + "a non-staff snapshot holds no control_root" + ); + assert_eq!( + entry.seed, entry.current, + "an absent seed reads as equal to current" + ); + + let channel = entry.current.channels.first().expect("a private channel"); + assert_eq!(channel.name, "staff"); + assert_eq!(channel.epoch, Epoch(2)); + assert!(channel.key.is_some()); + + let written = serde_json::to_string(&parsed).expect("writes"); + assert!(written.contains("\"frags\":2")); + assert!( + !written.contains("\"seed\""), + "a seed equal to current is omitted" + ); + assert!( + !written.contains("\"current\":{\"community_id\""), + "an embedded snapshot omits community_id and inherits the entry's" + ); + + // Every writer emits the canonical spelling, so a non-canonical input is + // stabilized here and two devices converge on identical bytes. + let value: serde_json::Value = serde_json::from_str(&written).expect("valid"); + let owner = value["entries"][0]["current"]["owner"] + .as_str() + .expect("an owner"); + assert_eq!(owner, base64url::encode(&entry.current.owner.to_bytes())); + assert_ne!(owner, "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9"); + + // Reading its own output is a fixed point. + let again: CommunityList = serde_json::from_str(&written).expect("parses"); + assert_eq!(again, parsed); + assert_eq!(serde_json::to_string(&again).expect("writes"), written); + } + + #[test] + fn a_rename_rewrites_the_seed_cosmetics_and_collapses_the_snapshot() { + let owner = Keys::generate().public_key(); + let channel = ChannelId::from_bytes([0x9c; 32]); + + let mut current = material(id(0x11), owner, "New name", 5); + current.channels = vec![ChannelGrant { + id: channel, + key: Some("55".repeat(32)), + epoch: Epoch(4), + name: "new channel".to_owned(), + extra: Extra::default(), + }]; + + let mut seed = material(id(0x11), owner, "Old name", 1); + seed.relays = vec!["wss://stale.example".to_owned()]; + seed.channels = vec![ChannelGrant { + id: channel, + key: Some("55".repeat(32)), + epoch: Epoch(2), + name: "old channel".to_owned(), + extra: Extra::default(), + }]; + + let written = + serde_json::to_string(&list(vec![entry(id(0x11), seed, current.clone(), AT)])) + .expect("writes"); + let value: serde_json::Value = serde_json::from_str(&written).expect("valid"); + let written_seed = &value["entries"][0]["seed"]; + assert_eq!(written_seed["name"], "New name"); + assert_eq!(written_seed["relays"][0], "wss://relay.example"); + assert_eq!(written_seed["channels"][0]["name"], "new channel"); + assert_eq!( + written_seed["root_epoch"].as_u64(), + Some(1), + "the rewrite touches no key material" + ); + + // A seed that differs from current only in cosmetics is the same bytes + // after the rewrite, so it is omitted entirely. + let mut stale = material(id(0x11), owner, "Old name", 5); + stale.channels = current.channels.clone(); + let collapsed = serde_json::to_string(&list(vec![entry(id(0x11), stale, current, AT)])) + .expect("writes"); + assert!(!collapsed.contains("\"seed\"")); + } + const AT: u64 = 1_719_800_000_000; } diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index 495c5da3..ed8791f8 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -11,10 +11,10 @@ use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError}; -use crate::cord02::list::{canonical, union}; use crate::cord02::{ImageRef, MAX_RELAYS}; use crate::cord04::{TAG_SUBKIND, vsk}; use crate::derive::{TOKEN_LEN, verify_community_id}; +use crate::utils::{canonical, union}; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; pub const KIND_BUNDLE: u16 = 33301; diff --git a/crates/concord/src/utils/base64url.rs b/crates/concord/src/utils/base64url.rs new file mode 100644 index 00000000..e70ffbfd --- /dev/null +++ b/crates/concord/src/utils/base64url.rs @@ -0,0 +1,34 @@ +use anyhow::{Result, anyhow}; +use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; +use base64::{Engine as _, alphabet}; + +/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value +/// encoding at any depth. +/// +/// The reader tolerates non-zero trailing bits; the writer never emits them. +/// The spec's own worked example (`examples.md` §6.2) contains five such +/// values, and a reader cannot tell a mis-encoded named field from a correctly +/// encoded one, so the boundary is the writer's alone. +const BASE64URL: GeneralPurpose = GeneralPurpose::new( + &alphabet::URL_SAFE, + GeneralPurposeConfig::new() + .with_encode_padding(false) + .with_decode_padding_mode(DecodePaddingMode::RequireNone) + .with_decode_allow_trailing_bits(true), +); + +pub(crate) fn encode(bytes: &[u8]) -> String { + BASE64URL.encode(bytes) +} + +/// Decodes one 32-byte value, the width every §8 field has. +pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> { + let bytes = BASE64URL + .decode(value.trim()) + .map_err(|error| anyhow!("invalid base64url: {error}"))?; + + bytes + .as_slice() + .try_into() + .map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len())) +} diff --git a/crates/concord/src/utils/mod.rs b/crates/concord/src/utils/mod.rs index 622d1942..4ba24904 100644 --- a/crates/concord/src/utils/mod.rs +++ b/crates/concord/src/utils/mod.rs @@ -1,9 +1,13 @@ +pub mod base64url; pub mod derive; use anyhow::{Result, anyhow, bail}; use data_encoding::HEXLOWER; use rand::TryRng as _; use rand::rngs::SysRng; +use serde::Serialize; + +use crate::Extra; /// Uppercase and other non-canonical spellings are rejected. pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> { @@ -38,3 +42,34 @@ pub(crate) fn random_32() -> Result<[u8; 32]> { fill_random(&mut bytes)?; Ok(bytes) } + +/// Hex to unpadded base64url for one 32-byte §8 value. +pub(crate) fn hex32_to_base64(value: &str) -> Result { + Ok(base64url::encode(&decode_hex_32(value)?)) +} + +/// Unpadded base64url to lowercase hex for one 32-byte §8 value. +pub(crate) fn base64_to_hex32(value: &str) -> Result { + Ok(HEXLOWER.encode(&base64url::decode_32(value)?)) +} + +/// Canonical JSON bytes: the total-order tie-break every content merge uses. +pub(crate) fn canonical(value: &T) -> String { + serde_json::to_string(value).unwrap_or_default() +} + +/// Unions an unknown-field map. Where both sides carry a key, the +/// lexicographically lowest canonical bytes win, so two devices converge +/// instead of flapping. +pub(crate) fn union(into: &mut Extra, other: Extra) { + for (key, value) in other { + let replace = match into.get(&key) { + Some(existing) => canonical(&value) < canonical(existing), + None => true, + }; + + if replace { + into.insert(key, value); + } + } +} diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md new file mode 100644 index 00000000..c21e06c1 --- /dev/null +++ b/docs/concord-community-discovery-plan.md @@ -0,0 +1,241 @@ +# Concord discovery: why no community ever reaches `subscribe` + +Audit + fix plan. Read alongside `docs/concord-usage.md` and +`docs/concord-simplification-plan.md`. + +## Symptom + +`crates/community/src/lib.rs::subscribe` is never called, so no wrap is ever +subscribed to and the sidebar stays empty. `community load: 0 state document(s) +found` is the only clue. + +## Root cause + +`CommunityRegistry::load` only ever reads the **local database**. Nothing in the +discovery path touches a relay. + +``` +community::init + └─ SignerChanged → load + └─ sync::load + ├─ store::load_states(client) → client.database().query(..) // local only + └─ load_list(client, ..) → client.database().query(..) // local only, .limit(1) + → track([]) + → sync_subscriptions: `for community in self.communities` runs zero times + → subscribe never called + → no relay is ever queried + → the database never fills + → load stays empty forever +``` + +The loop is self-reinforcing: the local database is populated *by* the +subscriptions that the empty load prevents. That is why an account which belongs +to several communities in another client still shows nothing — a fresh install +has no `concord/*` state document, and coop has no way to ask for one. + +Confirmed by inspection: + +| Location | What it does | +| --- | --- | +| `crates/community/src/lib.rs:167-191` | `load` → `sync::load`, then `track(states)` | +| `crates/community/src/sync.rs:125-137` | `load` = `store::load_states` + `load_list` | +| `crates/concord/src/store.rs:314-341` | `load_states` queries `client.database()` only | +| `crates/community/src/sync.rs:139-153` | `load_list` queries `client.database()` only, `.limit(1)` | +| `crates/community/src/lib.rs:233-276` | `sync_subscriptions` skips everything when `communities` is empty | + +`subscribe` itself is correct. Do not debug it. + +## What the protocol actually says + +Read from the spec (`concord-protocol/concord`, the submodule referenced by +accordion.chat): `02.md` §8 and `examples.md` §6.2. + +A member's memberships live in the **Community List**, on relays: + +- **Kind `33302`**, addressable, NIP-44-encrypted to self, signed by the + member's real key, one event per **fragment** with `d` = the fragment index in + decimal (`"0"`, `"1"`, …). `13302` is explicitly **retired** ("the + single-event Community List, superseded by `33302` once it outgrew one event — + a replaceable kind cannot fragment", `02.md:314`). +- Every 32-byte value at **any depth** is unpadded base64url, not hex. This is + section-scoped: CORD-05 invite fields stay hex (`examples.md` §6.3). +- Join material is the membership subset — `owner, owner_salt, community_root, + root_epoch, control_pk, channels, relays, name`, plus `control_root` when + held. It is the *only* durable home of a member's keys. +- The two snapshots solve opposite problems: `seed` is the earliest epoch held + (backfill anchor), `current` the latest ("so a fresh device reconstructs the + Community instantly with no epoch-by-epoch walk"). `seed` is omitted when + equal to `current`; embedded snapshots omit `community_id` (inherited). +- A client holds the complete List when it holds a fragment at every index below + `frags`; it unions fragments and merges, so a partial read is safe. + +Two consequences for coop: + +1. **The state document is a coop invention.** `store::{save_state, load_state, + load_states}` write kind `30078` with `d = concord/`, signed by a + per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists + anywhere in the spec. It is a local cache and must never be treated as the + discovery source. +2. **Discovery is: fetch my `33302` from relays → materialize a community from + `current` join material → subscribe to its planes → fold.** The fold produces + the authoritative state; the List only supplies the keys to start. + +## Divergences (coop vs spec) + +| # | 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 | +| 5 | fetch from relays | local database only | +| 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 | + +Divergences 1–4 meant that even if the fetch existed, coop could neither read +what accordion wrote nor write something accordion could read. **Phase A is +done**, so 1–4 are resolved; 5–8 remain. + +## Plan + +Ordered so each phase is independently reviewable and testable. Nothing here +touches the frozen HKDF derivations or `cord01` envelope semantics. + +### Phase A — make the List interoperable (pure, no I/O) — DONE + +`crates/concord/src/cords/cord02/list.rs` + +1. `KIND_COMMUNITY_LIST` → `33302`; add `frags: u64` to `CommunityList` and + `is_complete(&self, frags) -> bool`. +2. Add a base64url codec for the §8 value set and apply it to every 32-byte + field at every depth. Because `JoinMaterial` currently types `owner` and + `control_pk` as `PublicKey` (nostr's hex serde), this needs either wire + newtypes or `serialize_with`/`deserialize_with` helpers. Keep it local to the + List: `cord05` stays hex. +3. Implement the two §8 MUSTs: omit `community_id` on an embedded snapshot, + omit `seed` when it byte-equals `current`, and rewrite `seed`'s cosmetic + fields (`name`, `relays`, each channel's `name`) from `current` on every + serialization. +4. `build_list_event`/`parse_list_event` take the fragment index and emit/read + the `d` tag. + +Tests: round-trip the `examples.md` §6.2 payload verbatim; `merge` convergence +for two devices and mixed-age fragments; `frags` disagreement resolves to the +larger value; a repack does not shed unknown fields. + +**As built.** The §8 rules live behind private wire structs (`WireList`, +`WireEntry`, `WireSnapshot`, `WireChannel`), so a writer re-encodes on every +serialization while the public types keep their internal hex/`PublicKey` +spellings and `cord05` stays hex. Three deviations from the sketch above: + +- `is_complete` takes the set of fragment indices a client holds, not a count: + a count is wrong when the indices are sparse. +- The reader tolerates non-zero base64url trailing bits. The spec's own §6.2 +example has five such values, so a strict decoder rejects the worked example; + the writer still emits the canonical spelling. +- The third omission MUST was implemented too: an entry whose `added_at` does + not outrun its tombstone is not written. It is a serialization rule exactly + 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. + +### Phase B — materialize a community from join material (pure) + +`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs` + +1. `CommunityState::from_join_material(material: &JoinMaterial, added_at_ms: + u64) -> Result`: identity/owner/salt/root/root_epoch from the material; + `control_pks = { root_epoch → control_pk }`; `relays` parsed; `channels` from + the grants; `control_root` when present; `heads` empty (the first control + fold fills them); `banned` empty; `dissolved` false. +2. Carry the private channel key: add `key: Option<[u8; 32]>` to + `ChannelKeyRef` (or a parallel map) so a grant's `key` has a home. Without + this, a private channel is silently read-only-until-rekey. + +Tests: a material with and without `control_root`; a private grant's key +survives; `from_join_material` then `planes()` yields the control `control_pk` +plus the guestbook and public channels, i.e. a subscription filter that +addresses real planes. + +### Phase C — fetch the List from relays, then load + +`crates/community/src/sync.rs` + +1. `load` becomes: + - resolve where to ask: the account's NIP-65 write relays (kind `10002`) plus + the pool's connected relays. If only the app's bootstrap relays are queried, + a List published by another client (e.g. accordion on `relay.damus.io` / + `nos.lol`) will simply not be found. + - `client.fetch_events(Filter::new().kind(33302).author(self_pk))` — one + filter returns every fragment. Fetched events are persisted by the client + (`nostr-sdk/src/relay/inner.rs:1291`), so the database read stays valid. + - merge fragments → `CommunityList`. + - for each entry whose `is_live(&id)`: if a state document exists, keep its + `heads` (the fold's authority) and refresh relays/keys from `current`; + otherwise `from_join_material(..)`. + - `store::save_state` each result so the next `load` is warm. +2. `load_list` keeps reading `client.database()` — after the fetch it is + populated. It must stop using `.limit(1)`. +3. Drop the `states.retain(..)` shape: the List is now the *source* of states, + not just a filter over local ones. A local state whose membership is + tombstoned is still dropped, but a List entry with no local state now + produces one. + +Tests (no network, `nostr-memory`): a `33302` fragment written by the account is +discovered with **no** state document present; a tombstoned id is dropped; a +missing fragment leaves the rest usable. A `nostr_sdk::local_relay::LocalRelay` +(in-process relay, public in this pinned revision) can drive the real +fetch/subscribe path end to end. + +### Phase D — publish + +`crates/community/src/sync.rs`, `crates/concord/src/store.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. + +### Phase E — verify live + +`RUST_LOG=info cargo run -p coop`, sign in with the accordion account that +already belongs to communities. Expect `community {id}: subscribing to ..` and +rows in the sidebar. This is the first time the path can be exercised at all. + +## Validation per phase + +- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D). +- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`. +- A is provable against the spec's worked example, so it needs no relay. +- C is provable with `nostr-memory` + `LocalRelay`, so it needs no network. +- E is the only step that needs real relays. + +## Risks and open decisions + +- **Base64url is case-significant and coop's ids are hex everywhere else.** + Confine the codec to `cord02::list`; any normalisation that case-folds will + silently corrupt §8 values. **Resolved in Phase A**: the codec is private to + `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. +- **Relay selection for the fetch is the difference between finding the account's + List and not.** NIP-65 write relays + pool, or a user-visible relay setting? +- **Private channels stay unreadable until `ChannelKeyRef` carries the grant key** + (Phase B.2). Public discovery works without it. +- **Two writers, one key.** Once coop publishes `33302`, an account used from + 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. +- **`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. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 95790af5..c77068da 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -413,18 +413,47 @@ 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).await?; -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 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 = cord02::list::build_list_event(&my_keys, &mine).await?; // kind 13302, NIP-44 to self +let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0 ``` +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 +fragment at every index below it. `merge` resolves a `frags` disagreement to the +larger value. (`13302`, the single-event List, is retired by the spec — a +replaceable kind cannot fragment.) + +The payload's 32-byte values are **unpadded base64url at every depth**, which is +section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on +every serialization, so its output is always the canonical 43-character spelling; +the reader also accepts non-zero trailing bits, because the spec's own worked +example contains them and no reader can tell a mis-encoded named field from a +correct one. The codec is `utils::base64url` and the wire structs behind the +List's `Serialize`/`Deserialize` are the only callers, so no other encoding path +is touched. + +Three write-time rules are folded into serialization, so an in-memory document +and its wire form differ: + +- an embedded snapshot omits `community_id` and inherits the entry's; +- `seed` is omitted when it equals `current`, and its cosmetic fields (`name`, + `relays`, each channel's `name`) are overwritten from `current` first, so a + rename collapses the snapshots instead of forking them; +- an entry whose `added_at` does not outrun its tombstone is omitted — the + tombstone alone carries the state. + `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. +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). ## GPUI integration @@ -545,7 +574,12 @@ client.subscribe(filter).with_id(sub_id).await?; 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. + the genesis locally without publishing it to the metadata's relays. Discovery + is local-only: `load` reads the state documents already in + `client.database()` and never fetches the account's CORD-02 Community List + (`33302`) from relays, so a fresh install — or one signing in as an account + that joined elsewhere — finds nothing and never subscribes. See + `docs/concord-community-discovery-plan.md`. - **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`,