This commit is contained in:
2026-09-15 16:30:05 +07:00
parent 5b72b07ca8
commit 1e4eb697a4
4 changed files with 290 additions and 2 deletions
+60 -1
View File
@@ -128,7 +128,7 @@ Concord is 8 CORDs. Building all at once contradicts requirement 1.
| Pins / Threads / WebXDC | Not needed for a first cut. |
| Deletes (`kind 5`) | Registered, not folded: a message another client deleted still renders here. Same shape as M6's edits, so ~the same cost when someone asks. |
| Replies (`kind 1111`) | Reuse `plane.key.rumor` the same way M6 does; the work is the thread UI, not the wire format. |
| Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. |
| Community List (`33302`) | **Read-only as of M7** (§12): a Community joined in another client now appears here. Writing the List is still deferred — see M7's "not implemented". |
| Dissolution | Rare path. |
> **Consequence to be honest about:** the v1 Control fold does **not** enforce authorization. A `control_root` holder could publish forged metadata or channels and v1 would display it. This is bounded (only staff hold `control_root`, and the spec itself calls it "a spam gate, never authority") but it **is** a real gap. Document it in code and label the feature beta in the UI.
@@ -205,6 +205,7 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
| `ConcordModels.kt` | `Membership`, `CommunityInvite`, `CommunityMeta`, `ChannelMeta`, `ConcordChannel`, `ConcordMessage`, `ControlEdition` |
| `ConcordPlane.kt` | `SealForm`, `ChatBinding`, `PlaneKey` + `channelPlaneKey` / `guestbookPlaneKey` / `controlPlaneKey`, and the `rumor()` / `wrap()` / `unwrap()` extensions — the CORD-01 stack with the CORD-03 §3 binding built in |
| `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/<naddr>#<fragment>` decoder, relay dictionary |
| `ConcordCommunityList.kt` | **M7.** CORD-02 §8's wire models and the *reader's* merges: union fragments, newest entry wins, tombstones subtract. Produces `Membership`s through the invite path's own `problems()` / `toMembership()` |
| `ConcordStore.kt` | Membership persistence (`AppStorage.setSecret`), LMDB index events, Control edition storage |
| `ConcordControl.kt` | Control fold: group by `eid`, take highest `ev` with intact `ep` chain; project `vsk 0` / `vsk 2` |
| `ConcordManager.kt` | Subscriptions, relay connect, notification routing, send-message, join/leave |
@@ -276,6 +277,13 @@ Wired in M6:
| `composeApp/.../screens/chat/ChatMessage.kt` | `MessageReactions` takes `(emojis, total)` instead of `List<ReactionGroup>`, so Channels can share it |
| `composeApp/.../screens/communities/ChannelScreen.kt` | long-press action row, reaction chips, the Edit flow and its banner |
Wired in M7:
| File | Change |
|---|---|
| `shared/.../concord/ConcordCommunityList.kt` | new — §8 wire models, `listedMemberships()` |
| `shared/.../concord/ConcordManager.kt` | `adoptCommunityList()`, called first in `sync()` |
---
## 6. Crypto layer — exact algorithm
@@ -926,6 +934,56 @@ gains two more steps because of it.
---
### M7 — Community List discovery — **done**
An invite hands the keys to *one* device. The only path from "joined in another client" to "visible
here" is CORD-02 §8's Community List: kind `33302`, NIP-44-encrypted to self, fragmented, whose
entries carry the whole *join material* — keys included.
What shipped:
- **`ConcordCommunityList.kt`** — the §8 wire models plus the reader's merges, in the same shape as
`ConcordInvite.kt`: one file per wire concern.
- **`ConcordManager.adoptCommunityList()`**, called at the top of `sync()` so adopted Communities are
subscribed by the same loop that subscribes the rest. Fetch is `Filter(kind 33302, author = us)`
over `ReqTarget.auto` — the pattern `RelayManager.fetchMsgRelays` already uses for finding a
self-published document — then `signer.nip44DecryptAsync(me, content)` per event and a union.
- **§8's encoding, which is the one place this format differs from every other Concord document:**
every 32-byte value is unpadded base64url at *any* depth, join material and each `channels` entry
included, where the rest of the protocol is lowercase hex (CORD-01, Encoding). Values are decoded
strictly — exactly 32 bytes or the entry is dropped — then normalised to hex, so nothing downstream
knows the difference.
- **Reuse, not a parallel copy.** A decoded entry is rebuilt as a `CommunityInvite`, which means the
invite path's own `problems()` runs on it: the entry must *prove* its `community_id` as
`sha256("concord/community" ‖ owner ‖ owner_salt)`, and its channel ids and keys must be 32 bytes.
`toMembership()` then does the projection. Entry data that fails any of that is dropped, not
rendered.
- **Only the merges a reader needs.** The newest entry per Community wins, and a Community whose
tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak,
fragment repacks and the 65 KiB ceiling are all *writer* rules — they exist to keep two devices
byte-identical, and a client that never republishes cannot flap. Fragments are unioned, so a
multi-fragment List works; `frags` itself is ignored.
- **Add-only.** A tombstone for a Community this device does not hold is honoured; nothing already
joined here is ever removed, because Coop does not write the List and so cannot tell which of two
states is newer.
- **One log line**, because every failure mode here is silent: the fetch returning nothing, an
undecryptable fragment, and an entry failing self-certification all look identical from the UI.
`Concord: Community List gave N fragment(s), M membership(s), K new` is the difference between "the
feature is broken" and "there was nothing to find".
**Not implemented, deliberately:** writing the List. So a Community joined *in Coop* still does not
appear in another client, and Coop never republishes a fragment. §8 is explicit about the cost of
getting a writer wrong — a client that drops a field it does not understand "destroys that material
on every device its holder owns the moment it republishes" — so the read path is proven first.
Also absent: `seed`-based epoch backfill (moot while CORD-06 rekeys are deferred) and live
subscription to `33302` (discovery runs at startup, like everything else in `sync()`).
**Verified:** compilation on both targets — `:shared:compileKotlinIosSimulatorArm64` and
`:shared:compileDebugKotlinAndroid`. Nothing else: the parse is exercised only by a real List, which
requires the identity that minted it.
---
## 13. Risks and open decisions
### 13.1 Spec conflicts — resolve before writing byte-exact code
@@ -958,6 +1016,7 @@ gains two more steps because of it.
| 18 | **`restored` must not flip back on reset.** If `reset()` set it false, the Communities screen would spin forever after a logout, because nothing re-runs `restore()` until the notification pump is rebuilt and the pump has no restart path. | `restored` means "memberships have been loaded", which stays true after they are dropped. Stated in a comment at the assignment. |
| 19 | **M6's edit shape is illustrative, not normative.** `examples.md` §2.5 says so in as many words: "The CORDs register the kind but don't yet pin its fields; this shape is illustrative." The reaction shape (§2.3) is pinned — NIP-25 with `e`/`p`/`k` — but the edit's `e`-alone layout is a guess the spec invites others to make differently. | Unfixable from here; it is a spec gap, not an implementation choice. Both shapes are one call to `publish` with a different tag list, so a revision is a one-place change (§11's frozen-constants discipline). Report it upstream rather than papering over it. |
| 20 | **Enforcement of edits is local.** Our fold refuses an edit whose author is not the message's, but nothing stops another client rendering a forged one — and nothing *should*, since a receiver-side check is the whole enforcement model. | This is the protocol, not a bug: enforcement is rejection ("an action that does not trace to the owner is not authority"). The one thing to keep is the check itself, so a hostile edit never lands *here*. |
| 21 | **M7 discovery can fail three ways, all silently.** (a) the other client never published a `33302` at all; (b) it published to relays `ReqTarget.auto` does not reach, since Coop's client knows only its bootstrap plus indexer set; (c) the entry fails the self-certifying `community_id` check, which drops it by design. | (c) is correct behaviour. (a) and (b) are indistinguishable from the UI, which is why `adoptCommunityList` logs fragment/entry counts — an empty List and an unreachable one must not look the same. If (b) turns out to be the case, the fix is to add the user's own NIP-65 relays to the client before the fetch, the way `MessageManager.connectMsgRelays` does for chat rooms. |
### 13.3 Closed decision