2 Commits
Author SHA1 Message Date
reya 9a391ca76d load community list 2026-09-19 08:56:41 +07:00
reya 8aad0685ad update concord store 2026-09-19 08:46:49 +07:00
5 changed files with 489 additions and 61 deletions
+32 -3
View File
@@ -27,6 +27,7 @@ impl Global for GlobalCommunityRegistry {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Signal {
Event(CommunityId),
List,
}
impl EventEmitter<CommunityEvent> for CommunityRegistry {}
@@ -67,6 +68,7 @@ impl CommunityRegistry {
if event.signer_changed() {
this.reset(cx);
this.handle_notifications(cx);
this.subscribe_list(cx);
this.load(cx);
}
}));
@@ -76,6 +78,7 @@ impl CommunityRegistry {
.update(cx, |this, cx| {
this.handle_notifications(cx);
if nostr.read(cx).current_user().is_some() {
this.subscribe_list(cx);
this.load(cx);
}
})
@@ -163,6 +166,25 @@ impl CommunityRegistry {
cx.notify();
}
/// Subscribe to the account's community list.
fn subscribe_list(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let client = nostr.read(cx).client();
self.tasks.push(cx.spawn(async move |this, cx| {
let self_pk = signer.get_public_key_async().await?;
if let Err(error) = sync::subscribe_list(&client, self_pk).await {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
Ok(())
}));
}
/// Discover the account's communities in the local database.
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -298,6 +320,11 @@ impl CommunityRegistry {
continue;
};
if sync::is_list_subscription(&subscription_id) {
tx.send_async(Signal::List).await?;
continue;
}
if event.kind != Kind::from(KIND_WRAP) {
continue;
}
@@ -313,10 +340,12 @@ impl CommunityRegistry {
}));
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(Signal::Event(id)) = rx.recv_async().await {
this.update(cx, |this, cx| this.refresh(id, cx))?;
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Event(id) => this.update(cx, |this, cx| this.refresh(id, cx))?,
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
}
}
Ok(())
}));
}
+214 -11
View File
@@ -121,21 +121,135 @@ where
Ok(state)
}
/// Discovers the current account's communities from the local database.
/// The subscription id carrying the account's own Community List.
pub const LIST_SUBSCRIPTION: &str = "concord/list";
pub fn list_subscription_id() -> SubscriptionId {
SubscriptionId::new(LIST_SUBSCRIPTION)
}
pub fn is_list_subscription(id: &SubscriptionId) -> bool {
id.as_str() == LIST_SUBSCRIPTION
}
/// Subscribes to the account's community list.
pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> {
let id = list_subscription_id();
client.unsubscribe(&id).await?;
let filter = Filter::new()
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
.author(self_pk);
let output = client
.subscribe(ReqTarget::auto(vec![filter]))
.with_id(id)
.await?;
if !output.failed.is_empty() {
log::warn!(
"community list: {} relay(s) rejected the subscription",
output.failed.len()
);
}
Ok(())
}
/// Discovers the current account's communities: every live membership the List
/// carries, plus any locally-held membership the List does not mention.
///
/// A held membership is dropped only when the List carries a tombstone at least
/// as new as it, because absence from the List is never a fact (§8).
pub async fn load(
client: &Client,
signer: &UniversalSigner,
self_pk: PublicKey,
) -> Result<Vec<CommunityState>> {
let mut states = store::load_states(client).await?;
let list = match load_list(client, signer, self_pk).await? {
Some(list) => list,
None => return store::load_states(client).await,
};
if let Some(list) = load_list(client, signer, self_pk).await? {
states.retain(|state| list.is_live(&state.id));
let mut held: BTreeMap<CommunityId, CommunityState> = store::load_states(client)
.await?
.into_iter()
.map(|state| (state.id, state))
.collect();
held.retain(|id, state| !retired(&list, id, state.added_at_ms));
for entry in &list.entries {
if !list.is_live(&entry.community_id) {
continue;
}
let fresh = match CommunityState::from_join_material(&entry.current, entry.added_at) {
Ok(fresh) => fresh,
Err(error) => {
log::warn!(
"ignoring unreadable community {} from the list: {error}",
entry.community_id.to_hex()
);
continue;
}
};
let state = match held.remove(&entry.community_id) {
Some(materialized) => refresh(materialized, fresh),
None => fresh,
};
store::save_state(client, &state).await?;
held.insert(entry.community_id, state);
}
Ok(states)
Ok(held.into_values().collect())
}
fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool {
list.tombstones
.iter()
.find(|tombstone| tombstone.community_id == *id)
.is_some_and(|tombstone| tombstone.removed_at >= added_at_ms)
}
fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
held.owner = fresh.owner;
held.owner_salt = fresh.owner_salt;
held.community_root = fresh.community_root;
held.root_epoch = fresh.root_epoch;
held.added_at_ms = fresh.added_at_ms;
if fresh.control_root.is_some() {
held.control_root = fresh.control_root;
}
for (epoch, address) in fresh.control_pks {
held.control_pks.insert(epoch, address);
}
held.relays = fresh.relays;
for channel in fresh.channels {
match held.channels.iter_mut().find(|held| held.id == channel.id) {
Some(held) => {
held.name = channel.name;
held.epoch = channel.epoch;
if channel.private {
held.private = true;
held.key = channel.key;
}
}
None => held.channels.push(channel),
}
}
held
}
/// Every fragment of the account's list in the local database, merged.
async fn load_list(
client: &Client,
signer: &UniversalSigner,
@@ -143,14 +257,40 @@ async fn load_list(
) -> Result<Option<CommunityList>> {
let filter = Filter::new()
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
.author(self_pk)
.limit(1);
.author(self_pk);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(None);
};
let mut newest: BTreeMap<u64, Event> = BTreeMap::new();
Ok(Some(cord02::list::parse_list_event(signer, &event).await?))
for event in client.database().query(filter).await? {
let Ok(index) = cord02::list::fragment_index(&event) else {
continue;
};
match newest.get(&index) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(index, event);
}
}
}
let mut merged: Option<CommunityList> = None;
for event in newest.into_values() {
match cord02::list::parse_list_event(signer, &event).await {
Ok(list) => {
merged = Some(match merged {
Some(held) => cord02::list::merge(held, list),
None => list,
});
}
Err(error) => {
log::warn!("ignoring unreadable community list {}: {error}", event.id);
}
}
}
Ok(merged)
}
/// Rebuilds a community from the wraps already in the local database.
@@ -269,6 +409,69 @@ mod tests {
.build()
}
#[test]
fn planes_address_the_control_guestbook_and_only_public_channels() {
let owner = Keys::generate().public_key();
let control_pk = Keys::generate().public_key();
let general = ChannelId::from_bytes([0x9c; 32]);
let state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
owner,
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::from([(0, control_pk)]),
channels: vec![
concord::store::ChannelKeyRef {
id: general,
name: "general".to_owned(),
private: false,
epoch: Epoch(0),
key: None,
},
concord::store::ChannelKeyRef {
id: ChannelId::from_bytes([0x9d; 32]),
name: "staff".to_owned(),
private: true,
epoch: Epoch(0),
key: Some([0x04; 32]),
},
],
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms: 0,
};
let planes = planes(&state).expect("planes");
// Control at the root epoch, the guestbook, and the public channel. The
// private channel is skipped: its address derives from the granted key,
// not the community_root.
assert_eq!(planes.len(), 3);
assert!(planes.iter().any(|plane| plane.address == control_pk));
assert!(
planes
.iter()
.any(|plane| matches!(plane.kind, PlaneKind::Guestbook))
);
assert!(
planes
.iter()
.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<PublicKey> = 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 {
cord02::CommunityMetadata {
name: name.to_owned(),
+140 -2
View File
@@ -7,13 +7,14 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::list::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::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -140,6 +141,11 @@ pub struct ChannelKeyRef {
pub name: String,
pub private: bool,
pub epoch: Epoch,
/// The channel's read secret when the member was granted it.
///
/// A public channel derives its key from the `community_root` and carries none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<[u8; 32]>,
}
/// One local document per community, keyed by `concord/<community_id>`.
@@ -201,6 +207,7 @@ impl CommunityState {
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
key: None,
});
}
_ => {}
@@ -234,6 +241,52 @@ impl CommunityState {
})
}
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
let control_pks = match material.control_pk {
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
None => BTreeMap::new(),
};
let mut channels = Vec::with_capacity(material.channels.len());
for grant in &material.channels {
let key = match &grant.key {
Some(key) => Some(decode_hex_32(key)?),
None => None,
};
channels.push(ChannelKeyRef {
id: grant.id,
name: grant.name.clone(),
private: key.is_some(),
epoch: grant.epoch,
key,
});
}
Ok(Self {
id: material.community_id,
owner: material.owner,
owner_salt: decode_hex_32(&material.owner_salt)?,
community_root: decode_hex_32(&material.community_root)?,
root_epoch: material.root_epoch,
control_root: match &material.control_root {
Some(root) => Some(decode_hex_32(root)?),
None => None,
},
control_pks,
channels,
relays: material
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect(),
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
@@ -276,6 +329,7 @@ impl CommunityState {
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
key: None,
}),
None => {}
}
@@ -445,9 +499,10 @@ async fn fetch_page(
#[cfg(test)]
mod tests {
use super::*;
use crate::Epoch;
use crate::cord03::{build_message, seal_rumor};
use crate::cord05::ChannelGrant;
use crate::derive::channel_group_key;
use crate::{Epoch, Extra};
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
@@ -527,6 +582,89 @@ mod tests {
);
}
#[test]
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
let owner = Keys::generate().public_key();
let control_pk = Keys::generate().public_key();
let staff = ChannelId::from_bytes([0x9c; 32]);
let general = ChannelId::from_bytes([0x9d; 32]);
let material = JoinMaterial {
community_id: CommunityId::from_bytes([0x42; 32]),
owner,
owner_salt: "01".repeat(32),
community_root: "02".repeat(32),
root_epoch: Epoch(3),
control_pk: Some(control_pk),
control_root: Some("03".repeat(32)),
channels: vec![
ChannelGrant {
id: staff,
key: Some("04".repeat(32)),
epoch: Epoch(2),
name: "staff".to_owned(),
extra: Extra::default(),
},
ChannelGrant {
id: general,
key: None,
epoch: Epoch(0),
name: "general".to_owned(),
extra: Extra::default(),
},
],
relays: vec!["wss://relay.example".to_owned()],
name: "Room".to_owned(),
extra: Extra::default(),
};
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
assert_eq!(state.id, material.community_id);
assert_eq!(state.owner, owner);
assert_eq!(state.owner_salt, [0x01; 32]);
assert_eq!(state.community_root, [0x02; 32]);
assert_eq!(state.root_epoch, Epoch(3));
assert_eq!(state.control_root, Some([0x03; 32]));
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
assert!(
state.heads.is_empty(),
"the first control fold fills the heads"
);
assert!(state.banned.is_empty());
assert!(!state.dissolved);
assert_eq!(state.relays.len(), 1);
assert_eq!(state.added_at_ms, 7);
// A granted key lands on the channel and makes it private; a grant with
// no key is a public channel.
let granted = state
.channels
.iter()
.find(|c| c.id == staff)
.expect("staff");
assert!(granted.private);
assert_eq!(granted.key, Some([0x04; 32]));
assert_eq!(granted.epoch, Epoch(2));
assert_eq!(granted.name, "staff");
let public = state
.channels
.iter()
.find(|c| c.id == general)
.expect("general");
assert!(!public.private);
assert_eq!(public.key, None);
// A member who is not staff carries no control_root, but reading needs no
// secret: the address rides in the material either way.
let mut member = material.clone();
member.control_root = None;
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
assert_eq!(state.control_root, None);
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
}
#[test]
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
smol::block_on(async {
+82 -36
View File
@@ -76,7 +76,7 @@ Two consequences for coop:
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
2. **Discovery is: subscribe to my `33302` → 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.
@@ -88,14 +88,15 @@ Two consequences for coop:
| 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 |
| 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 |
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.
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).
## Plan
@@ -143,7 +144,7 @@ example has five such values, so a strict decoder rejects the worked example;
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)
### Phase B — materialize a community from join material (pure) — DONE
`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs`
@@ -161,35 +162,74 @@ 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
**As built.** `from_join_material` does not verify `community_id` against
`owner`/`owner_salt`: the List is signed by the member's own key and encrypted
to self, and the invite path already validates that binding in
`CommunityInvite::validate`. `private` on a materialized channel is simply
`key.is_some()` — the spec's `channels` carry only the Private Channel keys a
member was granted, so a grant with no key is a public channel. Nothing else
changed: `from_genesis` and `apply_fold` construct every channel with
`key: None`, and `planes()` still skips private channels, whose address derives
from the granted key rather than the `community_root`. Carrying the key is what
makes subscribing to them possible later; it is not needed to fix discovery.
`crates/community/src/sync.rs`
Two tests. In `concord`, `from_join_material` (with and without `control_root`,
a granted key surviving, a public grant staying keyless). In `community`,
`planes()` plus `subscription_filter` over a state built field-by-field (control
+ guestbook + public channel addressed, private skipped) — `JoinMaterial` and
`ChannelGrant` cannot be constructed from `community` because their `extra`
field's type is crate-private, so the materialization and the plane derivation
are each proved where they live.
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.
### Phase C — the List drives `load` — DONE
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.
`crates/community/src/sync.rs`, `crates/community/src/lib.rs`
1. `subscribe_list(client, self_pk)` subscribes to `Kind::Custom(33302)`
`author(self_pk)` under a dedicated `concord/list` subscription id, using
`ReqTarget::auto`. With gossip enabled, `auto` breaks the filter down by
author, so it queries the account's NIP-65 write relays and adds/connects
them itself — bootstrap relays alone would miss a List published elsewhere.
2. `CommunityRegistry` calls `subscribe_list` once per signer (signer change and
the initial defer). It is deliberately **not** called from `load`:
re-subscribing on every List event would re-deliver the List and loop. `reset`
does not unsubscribe it either — `subscribe_list` replaces the subscription
itself, and a `reset`-issued unsubscribe could race the replacement and cancel
discovery.
3. The notification listener routes a `concord/list` event to a new `Signal::List`,
whose consumer re-runs `load`. Community planes keep using `Signal::Event(id)`.
4. `load_list` reads every `33302` event by `self_pk` from the database, keeps the
newest event per fragment index, decrypts and `merge`s them. `.limit(1)` is gone.
An incomplete List is read normally — a missing fragment is news not yet heard.
5. `load` unions two sources: every live List entry (materialized with
`from_join_material`, or refreshed if a state document already exists) and every
held local state the List does not mention. A held membership is dropped only
when a tombstone outranks its `added_at_ms`; absence from the List is never a
fact. Each list-derived state is `save_state`d, so the next `load` is warm.
6. `refresh(held, fresh)` keeps the fold's authority (`heads`, `banned`,
`dissolved`) and the control planes it learned, and takes the List's identity,
relays, and channel keys. Channels are merged by id rather than replaced, so a
public channel the fold discovered is not shed by a List snapshot that predates
it.
**As built, deviating from the sketch above.** The plan called for
`client.fetch_events(..)`; the SDK's own recommendation is to keep the request
path on a subscription and read the database. This is safer than it sounds: a
relay's event is persisted at `nostr-sdk/src/relay/inner.rs:1291` **before** the
notification is emitted, so a subscription plus a database read loses nothing and
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.
### Phase D — publish
@@ -214,7 +254,9 @@ rows in the sidebar. This is the first time the path can be exercised at all.
- `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.
- C is provable with `nostr-memory`: fragments are built with `build_list_event`
and saved as the subscription would have, then `load` reads them. No relay,
no `LocalRelay`.
- E is the only step that needs real relays.
## Risks and open decisions
@@ -228,10 +270,14 @@ rows in the sidebar. This is the first time the path can be exercised at all.
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.
- **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
published only to relays with no NIP-65 entry is still unreachable; that is a
user-visible relay setting if it ever bites.
- **Private channels stay unsubscribed until `planes()` derives their address
from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the
discovery fix does not need it). Public discovery works regardless.
- **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
+21 -9
View File
@@ -455,6 +455,14 @@ 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).
Discovery is a **subscription, not a fetch**: subscribe with
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the
fragments back out of `client.database()`. The client persists a relay's event
before it notifies, so a subscription plus a database read loses nothing and
needs no explicit save. Parse each event with `parse_list_event`, keep the newest
per `fragment_index`, and `merge` them — reading an incomplete List is safe, since
a missing fragment is only news not yet heard.
## GPUI integration
`crates/concord` stays GPUI-free; the registry and sync engine live in
@@ -575,10 +583,13 @@ client.subscribe(filter).with_id(sub_id).await?;
`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
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
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
read is always current. A `concord/list` notification re-runs `load`, which
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`.
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
@@ -595,8 +606,9 @@ client.subscribe(filter).with_id(sub_id).await?;
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
that handler must route by subscription id before any concord subscription goes
live, or every stream wrap lands in the DM trash and raises a toast.
- **No plane key can be persisted yet.** `CommunityState` has nowhere to keep a
key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a
client can verify a rotation and still lose it on restart — history under a
prior root or a prior channel epoch is unreadable until that schema change
lands.
- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
nowhere to keep a key a rotation delivered, so a client can verify a rotation
and still lose it on restart — history under a prior root or a prior channel
epoch is unreadable until that schema change lands. (A granted private-channel
key does now have a home: `ChannelKeyRef.key`, filled by
`CommunityState::from_join_material`.)