update publish community
This commit is contained in:
@@ -84,19 +84,18 @@ Two consequences for coop:
|
||||
|
||||
| # | Spec | coop today |
|
||||
| --- | --- | --- |
|
||||
| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) |
|
||||
| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` |
|
||||
| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` |
|
||||
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present |
|
||||
| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** |
|
||||
| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)` — **fixed in Phase A** |
|
||||
| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key` — **fixed in Phase A** |
|
||||
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** |
|
||||
| 5 | fetch from relays | local database only — **fixed in Phase C** |
|
||||
| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` |
|
||||
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs |
|
||||
| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field |
|
||||
| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis` — **fixed in Phase B** |
|
||||
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** |
|
||||
| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed |
|
||||
|
||||
Divergences 1–4 meant that even if the fetch existed, coop could neither read
|
||||
what accordion wrote nor write something accordion could read. **Phases A, B and
|
||||
C are done**, so 1–6 are resolved; 7 and 8 remain (8 only in that private planes
|
||||
are still not subscribed).
|
||||
Divergences 1–7 are resolved. 8 remains, in the narrow sense that `planes()`
|
||||
still skips private channels rather than deriving their addresses from the
|
||||
granted key.
|
||||
|
||||
## Plan
|
||||
|
||||
@@ -140,9 +139,9 @@ example has five such values, so a strict decoder rejects the worked example;
|
||||
like the other two, so it belongs here rather than in Phase D.
|
||||
|
||||
`parse_list_event` validates the `d` tag but returns just the `CommunityList`;
|
||||
`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until
|
||||
Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has
|
||||
no membership limit, and Phase D's fragmentation is what removes the cap.
|
||||
`fragment_index(event)` reads the index, which kept `sync.rs` untouched until
|
||||
Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no
|
||||
membership limit, and removing the cap needs write-time fragmentation.
|
||||
|
||||
### Phase B — materialize a community from join material (pure) — DONE
|
||||
|
||||
@@ -221,27 +220,53 @@ needs no explicit save. The subscription is set up with `ReqTarget::auto` rather
|
||||
than a hand-built NIP-65 relay map, because gossip already resolves the author's
|
||||
write relays and connects them on demand.
|
||||
|
||||
Tests (no network): a fragment in the database with **no** state document
|
||||
materializes a community and writes one; a tombstone at `u64::MAX` drops a held
|
||||
membership; a two-fragment List with only fragment 0 delivered still yields its
|
||||
membership; a held membership the List never mentions is kept alongside the
|
||||
discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control
|
||||
planes while taking the List's keys; and the `concord/list` id is not read as a
|
||||
community subscription. Fragment events are built with `build_list_event` from a
|
||||
§8 JSON payload, so the test exercises the real decrypt-and-merge path without a
|
||||
relay.
|
||||
Tests (no network, in `crates/community/src/sync.rs`): a membership the List
|
||||
carries materializes a community even though no state document was ever written
|
||||
for it, and discovery writes the document so the next load is warm; a held
|
||||
membership the List never mentions is kept alongside the one it does; a tombstone
|
||||
outranks a held membership and drops it; and the `concord/list` id is not read as
|
||||
a community subscription. Fragment events are built with `store::list_entry` +
|
||||
`CommunityList::joined` + `build_list_event` and saved straight into a memory
|
||||
database, so the tests exercise the real seal/parse/merge path without a relay.
|
||||
|
||||
### Phase D — publish
|
||||
### Phase D — publish — DONE
|
||||
|
||||
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`
|
||||
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`,
|
||||
`crates/concord/src/cords/cord02/list.rs`
|
||||
|
||||
1. `create` appends to the List and publishes the fragment read-modify-write per
|
||||
§8, targeting the metadata's relays.
|
||||
2. `create` publishes the genesis wraps to those relays. Today it only
|
||||
`client.database().save_event(wrap)`s, so a created community is invisible to
|
||||
every other account.
|
||||
3. Leave uses a tombstone; a repack requires the complete List and is a
|
||||
non-goal until memberships outgrow one fragment.
|
||||
1. `create` mints the genesis, folds it into a state, and saves that state locally
|
||||
as before, then announces the community: the genesis wraps to its relay set,
|
||||
and the membership to the account's own List. Both publishes are best-effort —
|
||||
a relay that is down is a warning, not a failed create.
|
||||
2. The List write is a read-modify-write over the copy already held (§8). `create`
|
||||
reads the newest held fragment, unions its own entry in with
|
||||
`CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves
|
||||
it locally as a side effect of `send_event`, before any relay is resolved, so
|
||||
the fragment survives a relay that is down and no explicit database write is
|
||||
needed.
|
||||
3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable
|
||||
relay can never quietly keep the copy the write meant to replace.
|
||||
|
||||
**As built, deviating from the sketch above.** Three decisions the sketch did not
|
||||
cover:
|
||||
|
||||
- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the
|
||||
community's metadata relays. The List is the member's own document, and it is
|
||||
the same relay set `subscribe_list` resolves for its `author` filter — the two
|
||||
halves must agree or a write can land where nothing reads. The genesis wraps,
|
||||
which belong to the community and not the member, do go to the metadata relays.
|
||||
- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`'
|
||||
`extra` field is crate-private, so the community crate cannot build one; `name`
|
||||
is passed in because the state does not carry it — the name lives in the Control
|
||||
fold, and a created community has it in the metadata.
|
||||
- A List that already spans more than one fragment is **left alone**: placing a
|
||||
new membership needs a repack (which fragment does it belong in?), and §8 allows
|
||||
a repack only against the complete List. `load` keeps a membership the List
|
||||
never mentions, so the community is still tracked locally; the remote write is
|
||||
deferred with a warning rather than performed wrongly.
|
||||
|
||||
Tests: `create` records a membership the List round-trips, and a second create
|
||||
unions into the same document instead of replacing it.
|
||||
|
||||
### Phase E — verify live
|
||||
|
||||
@@ -267,9 +292,11 @@ rows in the sidebar. This is the first time the path can be exercised at all.
|
||||
`list.rs` and never case-folds.
|
||||
- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its
|
||||
only bound is the 65,536-byte *encoded event*. `fits()` still measures the
|
||||
NIP-44 plaintext, which understates that by roughly a third, so the count cap is
|
||||
kept as a conservative stopgap until Phase D measures the built event and
|
||||
fragments on write.
|
||||
NIP-44 plaintext, which understates that by roughly a third. Phase D kept the
|
||||
count cap and added a guard: a List that already spans more than one fragment is
|
||||
not appended to, because placing a new membership needs a repack. So a member
|
||||
with more than one fragment gets no remote write until fragmentation lands; the
|
||||
community stays local and visible.
|
||||
- **Relay selection is the difference between finding the account's List and
|
||||
not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the
|
||||
filter's author to their NIP-65 write relays and connects them. A List
|
||||
@@ -282,6 +309,28 @@ rows in the sidebar. This is the first time the path can be exercised at all.
|
||||
both accordion and coop has both clients writing the List. §8's
|
||||
read-modify-write is what keeps that from losing memberships — it is not
|
||||
optional.
|
||||
- **A create racing the first list sync can publish over an unseen List.**
|
||||
`record_membership` unions into what the local database holds, and on a fresh
|
||||
sign-in that is empty until the `concord/list` subscription has delivered. A
|
||||
create in that window writes a one-entry fragment 0, and an addressable relay
|
||||
then replaces the account's fuller List with it. The window is the ordinary
|
||||
sign-in-to-create interval, so it is small but not zero. The honest fix is to
|
||||
treat the List write as part of the sync loop — republish `list ∪ local
|
||||
memberships` whenever the subscription settles — rather than doing it inside
|
||||
`create`; an EOSE flag is not enough on its own, because an account with no
|
||||
NIP-65 relays never reaches EOSE and would then never write at all.
|
||||
- **`store::save_state` signs with a per-process random key.** Harmless while it
|
||||
stays local, but it means the state document can never be published or
|
||||
compared; if a future phase wants it on the wire, it needs the account signer.
|
||||
- **The deployed reference client still writes the retired kind `13302`.** The
|
||||
spec this plan implements (`concord-protocol/concord` `main`) moved the List to
|
||||
`33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that
|
||||
accordion.chat builds against still declares `13302`, single-event, capped at 50
|
||||
memberships, at its head of **2026-08-05**; accordion's pin predates even that
|
||||
(`0.0.0-concord-20260804145327`). So an account whose memberships were written
|
||||
by that build stores them under a kind coop deliberately does not read, and will
|
||||
show an empty sidebar until the client is updated to the fragmented kind. This
|
||||
is not a bug in the discovery path — Phases C and D are correct against the
|
||||
current spec — but it is the first thing to check if a live sign-in still shows
|
||||
nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the
|
||||
reference client moves.
|
||||
|
||||
+38
-12
@@ -79,6 +79,12 @@ save_state(&client, &state).await?;
|
||||
Put the community's relay list into `state.relays` and add those relays to the
|
||||
client explicitly — coop's client is a gossip client with no background refresh.
|
||||
|
||||
Creating is not finished until the membership is announced. The two writes are
|
||||
independent and both best-effort: the genesis wraps go to the community's
|
||||
relays, and the membership goes to the account's own Community List (below), so a
|
||||
new device — or another client — can find the community without an invite.
|
||||
`crates/community`'s `sync::create` performs both.
|
||||
|
||||
## Joining
|
||||
|
||||
An invite link resolves to a bundle:
|
||||
@@ -413,15 +419,29 @@ A member's own memberships, synced across their devices:
|
||||
```rust
|
||||
use concord::cord02::list;
|
||||
|
||||
let material = list::join_material(&invite, staff.then_some(&control_root));
|
||||
let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||
mine = list::merge(mine, list::CommunityList {
|
||||
entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
||||
..Default::default() // frags: 1
|
||||
});
|
||||
let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0
|
||||
let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material
|
||||
let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||
let mine = held.joined(entry); // community_id-keyed union
|
||||
let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
|
||||
client.send_event(&event).to_nip65().await?; // account's own write relays
|
||||
```
|
||||
|
||||
The publish needs no separate database write: `send_event` persists the event
|
||||
locally *before* it resolves targets, so the fragment is available to the
|
||||
`concord/list` read path even if every relay is unreachable.
|
||||
|
||||
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
|
||||
invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is
|
||||
what a write path uses after a create, join or rename. `joined` and `tombstoned`
|
||||
are the two mutations: both are `community_id`-keyed unions, so neither an append
|
||||
nor a leave can lose a membership the other writer has.
|
||||
|
||||
The entry is signed by the member's real key and sealed to that same key
|
||||
(`seal_to_self`), so only the member's devices read it — a stranger's
|
||||
`parse_list_event` fails rather than returning a partial list. Publishing goes to
|
||||
the member's **NIP-65 write relays**, the same set the `concord/list`
|
||||
subscription resolves for its `author` filter.
|
||||
|
||||
Kind `33302` is **addressable and fragmented**: one event per fragment, its `d`
|
||||
tag the fragment index in decimal. `frags` in the payload declares how many the
|
||||
List has, and `is_complete(held_indices)` answers whether the client has a
|
||||
@@ -452,8 +472,11 @@ and its wire form differ:
|
||||
strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
|
||||
the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired
|
||||
single-event design — §8 has **no membership limit**, its only bound is the
|
||||
65,536-byte encoded event, and the real fix is to start a new fragment on write
|
||||
(see `docs/concord-community-discovery-plan.md`, Phase D).
|
||||
65,536-byte encoded event, and the real fix is to start a new fragment on write.
|
||||
Until that lands, an append onto a List that already spans more than one fragment
|
||||
is refused rather than performed against a partial read, because placing a new
|
||||
membership needs a repack. The community stays local (`load` keeps a membership
|
||||
the List never mentions) and the write is deferred with a warning.
|
||||
|
||||
Discovery is a **subscription, not a fetch**: subscribe with
|
||||
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the
|
||||
@@ -581,8 +604,10 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
per state document, subscribes when a community's plane set changes, and
|
||||
re-folds on an inbound wrap. The sidebar observes the registry, logs
|
||||
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens
|
||||
a name prompt that calls `CommunityRegistry::create`. `create` still persists
|
||||
the genesis locally without publishing it to the metadata's relays. Discovery
|
||||
a name prompt that calls `CommunityRegistry::create`. `create` persists the
|
||||
genesis locally, publishes the wraps to the community's relays, and records the
|
||||
membership in the account's Community List — all best-effort, so a relay that is
|
||||
down warns without losing the community. Discovery
|
||||
subscribes to the account's CORD-02 Community List (`33302`) under the
|
||||
`concord/list` subscription id and reads the fragments back out of
|
||||
`client.database()` — the SDK persists a relay's event before notifying, so the
|
||||
@@ -590,7 +615,8 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
materializes a community from each live List entry (`from_join_material`) and
|
||||
keeps any state document the List does not mention, so a fresh install — or one
|
||||
signing in as an account that joined elsewhere — finds its communities. See
|
||||
`docs/concord-community-discovery-plan.md`.
|
||||
`docs/concord-community-discovery-plan.md` (including its note on the retired
|
||||
`13302` the current reference client still writes).
|
||||
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
||||
|
||||
Reference in New Issue
Block a user