# Concord Protocol Implementation Plan
Implementation plan for adding **Concord** communities and channels to Coop.
- Protocol repo: https://github.com/concord-protocol/concord
- Protocol docs: https://concordprotocol.org/learn/what-is-concord
- SDK: [`su.reya:nostr-sdk-kmp:0.3.2`](https://github.com/reyakov/nostr-sdk-ffi)
**Requirements this plan honors**
1. No over-engineering, no over-complication.
2. Reuse existing APIs and types from the nostr SDK; only create new code where the SDK has nothing.
3. Follow the existing Coop architecture (no new patterns, no new DI framework, no new persistence layer).
**Status**
| Milestone | State |
|---|---|
| M1 — crypto core | ✅ `e221eda` |
| M2 — plane stack | ✅ `8b0cbd2` |
| M3 — join + read | ✅ `871efa6` |
| M4 — write | ✅ `9230628` |
| M5 — UI | ✅ code complete, compiles on both targets, **has never been run on a device** |
| **M3/M4 interop smoke test** | ⬜ **not run — this is the acceptance gate** ([§12](#m3-interop-smoke-test--the-acceptance-gate), [§14](#14-verification-plan)) |
| M6 — reactions / edits | ⬜ optional |
**Out of scope for this app: community creation.** Coop **joins** communities; it never mints one. An
owner creates a community once — from a desktop client or a reference implementation — and hands
out invites. See [§3.2](#32-deferred--explicitly-out-of-v1).
**The one thing that matters most:** nothing has ever exchanged a message with a real Concord
client. Every milestone is verified against itself and against the spec's byte layouts, which
catches a typo in *our* code but not a misreading of the spec. Only the interop smoke test does
that, and it needs a human with a second client.
---
## Table of contents
1. [Verdict](#1-verdict)
2. [Capability check — exists vs. must be written](#2-capability-check--exists-vs-must-be-written)
3. [Scope — v1 vs. deferred](#3-scope--v1-vs-deferred)
4. [Architecture](#4-architecture)
5. [Files](#5-files)
6. [Crypto layer — exact algorithm](#6-crypto-layer--exact-algorithm)
7. [Wire format — exact stack](#7-wire-format--exact-stack)
8. [Subscriptions](#8-subscriptions)
9. [Persistence](#9-persistence)
10. [Invites](#10-invites)
11. [UI](#11-ui)
12. [Milestones](#12-milestones)
13. [Risks and open decisions](#13-risks-and-open-decisions)
14. [Verification plan](#14-verification-plan)
15. [Appendix A — Concord constants reference](#appendix-a--concord-constants-reference)
16. [Appendix B — Coop architecture reference](#appendix-b--coop-architecture-reference)
---
## 1. Verdict
Feasible without touching the Rust SDK. Every cryptographic primitive Concord needs is reachable through `nostr-sdk-kmp` **except HKDF-SHA256**, which Okio already gives us the pieces for (`ByteString.hmacSha256`) — and Okio is already a `commonMain` dependency.
The hard part is not crypto. It's that **Concord's wrap is NIP-59 *inverted*** (fixed author, ephemeral `p`), so it cannot ride the existing `MessageManager` giftwrap pipeline. It needs a parallel plane.
---
## 2. Capability check — exists vs. must be written
Verified against the published artifact `su.reya:nostr-sdk-kmp:0.3.2` (sources jar), not just documentation.
### 2.1 Exists — use it, don't wrap it
| Concord need | Existing API | Notes |
|---|---|---|
| NIP-44 with an arbitrary keypair | `nip44Encrypt(sk, pk, content, Nip44Version.V2)` `nip44Decrypt(sk, pk, payload)` | Concord's `conv_key = nip44_conversation_key(sk, pk)` **is** NIP-44's conversation key. Pass the plane's own keypair → self-ECDH happens for free. Do **not** hand-roll this. |
| x-only pubkey from a secret | `Keys(secretKey).publicKey()` | secp256k1 x-only, matches Concord's byte-exact rule |
| Arbitrary kind numbers | `Kind(1059u)`, `Kind(20013u)`, `Kind(20014u)`, `Kind(3308u)`, `Kind(9u)`… | `Kind(kind: UShort)` is a public constructor |
| Sign a wrap with a **derived** key | `EventBuilder(kind, content).tags(…).finalize(Keys(sk))` | `Keys` implements `NostrSigner` |
| Sign a seal with the **user's** key (incl. NIP-46 bunker) | `.finalizeAsync(nostr.signer)` | `UniversalSigner` already proxies this |
| Decode events | `Event.fromJson`, `UnsignedEvent.fromJson(…).ensureId()`, `Event.verify()` | Already used in `Messaging.extractRumor` |
| Multi-letter tags | `Tag.custom("channel", listOf("…"))`, `Tag.parse(List)`, `tag.kind()`, `tag.asVec()` | Filters only support **single-letter** tags |
| Subscribe / publish to specific relays | `client.subscribe(ReqTarget.manual(mapOf(relay to filters)), id)` `client.addRelay(url, RelayCapabilities.read())` `SendEventTarget.to(relays)` | Same idiom as `MessageManager.getUserMessages` |
| Local persistence | `client.database().saveEvent/query` (LMDB) | Mirror `MessageManager.setCachedRumor` |
| Secret storage | `AppStorage.setSecret` (AES-GCM via Android Keystore) | Room storage does **not**; roots must use this |
| NIP-40 expiry | `Tag.expiration(Timestamp)` | For deferred CORD-08 |
| SHA-256 | Okio `ByteString.sha256()` | `okio` already in `shared/commonMain` |
| HMAC-SHA256 | Okio `ByteString.hmacSha256(key)` | In `commonMain` → works on iOS targets too |
| base64url unpadded | `kotlin.io.encoding.Base64.UrlSafe` (Kotlin 2.4) | `Base64` already imported in `BlossomClient` |
| scalar validity test | `SecretKey.fromBytes(bytes)` throws for invalid scalars | This *is* `scalar_normalize`'s reject branch |
| hex ↔ bytes | Okio `ByteString.decodeHex()`, `ByteString.hex()` | |
### 2.2 Missing — must be written (small, pure Kotlin)
| Need | Size | Where |
|---|---|---|
| **HKDF-SHA256** (Extract + Expand) | ~15 lines on top of Okio | `concord/ConcordCrypto.kt` |
| `hkdf` info layout (`label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]`) | ~10 lines | same |
| `scalar_normalize` retry loop | ~10 lines | same |
| `community_id`, `edition_hash`, `prevcommit` | ~15 lines | same |
Everything else is composition of existing SDK calls.
---
## 3. Scope — v1 vs. deferred
Concord is 8 CORDs. Building all at once contradicts requirement 1.
### 3.1 v1 — ships
| CORD | What ships |
|---|---|
| **01** Private Streams | Full wrap/seal/rumor stack: `1059` / `20013` / `20014` + ephemeral `p` |
| **02** Communities | `community_id`, `community_root`, `control_root` (held, not used), epochs, Control/Chat/Guestbook planes. **Read-only Control fold for `vsk 0` (metadata) + `vsk 2` (channels) only.** Guestbook: publish `join` on join, do not fold. |
| **03** Channels | Public + Private, key derivation, `channel`/`epoch` binding checks, send/receive `kind 9`, with `kind 7` reactions and `kind 3302` edits folded onto it (M6) |
| **05** Invites | **Redeem only**: Direct Invite (`3313`) + public link bundle (`33301`) + fragment decoder. No minting. |
### 3.2 Deferred — explicitly out of v1
| CORD / feature | Why deferred |
|---|---|
| **Community creation** | **Decided, not deferred: out of scope for mobile.** Coop is a joiner. Owner-side minting (`community_id`, `community_root`, `control_root`, genesis `vsk 0` + `#general` `vsk 2`, written as `20014` plaintext seals) is a desktop/reference-client job. Two things follow from this and are worth stating: `concord/control-signer` is never used here, and the app cannot demo itself — there must be a real community minted elsewhere, which is exactly what the interop smoke test needs. |
| **04** Roles | Roster fold + `vac` citation + outranking rules. Large. Needed for moderation, not for chat. |
| **06** Rekeys | Epoch rotation. v1 reads the epoch it was invited to and holds old keys read-only. |
| **05** minting | Invite List (`13303`), Registry (`vsk 8`), revocation tombstones. |
| **07** A/V | Needs a broker + SFU and WebRTC. Genuinely a separate project. |
| **08** Disappearing | Cheap to add later; `Tag.expiration` exists. |
| 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`) | **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.
---
## 4. Architecture
### 4.1 Where it plugs in
```mermaid
graph TD
NF[Notifications stream] --> N[Nostr.handleNotifications]
N --> R{route by kind and author}
R -->|kind 1059, known plane pk| CM[ConcordManager]
R -->|kind 1059, otherwise| MW[MessageManager - unchanged]
R -->|rumor kind 3313| CM
CM --> CP[ConcordCrypto]
CM --> PL[ConcordPlane wrap and unwrap]
CM --> ST[ConcordStore]
ST --> AS[AppStorage - secrets]
ST --> DB[LMDB index events]
CM --> CR[ConcordRepository]
CR --> CV[ConcordViewModel]
CV --> CS[Screens]
```
### 4.2 The critical integration point
`Nostr.handleNotifications` is a **single notification pump** (`client.notifications()`). Calling `client.notifications()` a second time would race two consumers over the same stream. Concord must therefore be routed *through* the existing loop, with two new branches.
```kotlin
// shared/.../nostr/Nostr.kt
class Nostr(...) {
val messages = MessageManager(this)
val profiles = ProfileManager(this)
val relays = RelayManager(this)
val concord = ConcordManager(this) // NEW
```
**Branch A — plane wraps.** Concord wraps are `kind 1059` whose `author` is the plane's stream pubkey. The existing `extractRumor` would call `signer.nip44DecryptAsync(event.author(), …)` on them and fail. Route by author instead:
```kotlin
KindStandard.GIFT_WRAP -> {
if (concord.isPlaneAddress(event.author())) {
concord.handlePlaneEvent(event) // own decrypt path
} else {
giftWrapQueue.send(event) // existing NIP-17 path, untouched
}
}
```
**Branch B — Direct Invites.** A `kind 3313` invite rides a *standard* NIP-59 wrap (ephemeral author, `p` = recipient, `k` = `3313`), so it flows through the existing `extractRumor` fine — but `ChatRepository.updateRoomState` would then build a garbage `Room` from it. Intercept it before the app-level callback:
```kotlin
// inside the giftWrapQueue consumer, after extractRumor
if (rumor != null && concord.onInboxRumor(rumor)) continue
```
`ConcordManager.onInboxRumor` returns `true` when it consumed the rumor (i.e. it was a `3313`). This keeps all Concord kind knowledge inside the `concord` package, and requires **no changes to `ChatRepository`**.
> Add a comment on `handleNotifications` recording that it must remain the only `client.notifications()` consumer.
---
## 5. Files
### 5.1 New — `shared/src/commonMain/kotlin/su/reya/coop/concord/`
| File | Contents |
|---|---|
| `ConcordKind.kt` | All frozen constants in one place: kind numbers, `vsk` registry, permission bits, KDF label strings, tag names. Pure data, no SDK import — a spec revision is a one-file change |
| `ConcordCrypto.kt` | `hkdfSha256`, `hkdfInfo`, `groupSeed`, `isValidScalar`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, hex/bytes helpers |
| `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/#` 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 |
### 5.2 New — `shared/src/commonMain/kotlin/su/reya/coop/`
**Delivered in M5.**
| File | Contents |
|---|---|
| `Community.kt` | Display helpers over the read model — `CommunityState.displayName()` / `.unreadTotal()`, `ConcordMessage.timeLabel()` / `.dayLabel()`. Deliberately *not* a `RoomUiState`-style mirror: a Community's name is already in the fold, so there is no async lookup to model |
| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, flows forwarded straight from the manager, and one private `attempt` funnel that hops to `defaultDispatcher` and reports instead of throwing |
| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel`. `previewInvite` / `join` stay **suspend** so the Join screen owns its spinner |
| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel`, but re-reads on the manager's `revision` counter since a plane message is not pushed as an event |
### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/communities/`
**Delivered in M5.** All five live in one `communities` package, matching the `screens/chat/` convention.
| File | Contents |
|---|---|
| `CommunitiesScreen.kt` | Joined communities list + Direct Invite cards + FAB → Join |
| `CommunityScreen.kt` | Channel list for one community, split public / private |
| `ChannelScreen.kt` | Channel history, Discord-style. **Not in the original §5.3 list** — it was implied by the §11 table but omitted from the file plan |
| `JoinCommunityScreen.kt` | Paste link / scan QR → preview card → Join |
| `CommunityComponents.kt` | `CommunityRow`, `ChannelRow`, `DirectInviteCard`, `CommunityEmptyState`, `BetaNotice`, `shortCommunityId` |
**Deviation:** there is no `CommunityScreenViewModel`. That screen is a pure lookup of one `CommunityState` by id inside the existing `communities` flow, so an entry-scoped view model would have been a file with no state in it.
### 5.4 Tests — **none are kept**
Decision: no test files are maintained for this feature. M1–M4 were verified during development and the checks were then discarded, so the tree carries no Concord sources under `commonTest` or `iosTest`.
The consequence to plan around is in §14 and risk 13.
### 5.5 Modified
Wired in M3 (before the UI existed):
| File | Change |
|---|---|
| `shared/.../nostr/Nostr.kt` | `val concord`, `init(dbPath, storage)`, 2 routing branches |
| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
Wired in M5:
| File | Change |
|---|---|
| `shared/.../concord/ConcordManager.kt` | `restored` StateFlow, `dismissDirectInvite`, `reset` |
| `shared/.../concord/ConcordStore.kt` | `clearMemberships` |
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(communityId)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity(link)` |
| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, passed into `App(...)` |
| `composeApp/.../App.kt` | factory branch, activity-scoped `ConcordViewModel`, 4 × `entry<…>`, snackbar collector |
| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList`; QR results routed by `parseInviteLink`; `concordViewModel.resetInternalState()` on logout |
| `composeApp/.../screens/chat/ChatInput.kt` | `onUpload` / `onMicClick` became nullable, so a screen with neither shows a disabled send button instead of two dead ones |
| `composeApp/.../composeResources/drawable/ic_communities.xml` | icon (empty state) |
| `composeApp/.../composeResources/drawable/ic_lock.xml` | icon (private channel, beta notice) |
Wired in M6:
| File | Change |
|---|---|
| `shared/.../concord/ConcordModels.kt` | `ConcordReaction`; `ConcordMessage.reactions` / `.edited`; `toChannelMessages()` fold |
| `shared/.../concord/ConcordManager.kt` | `sendChannelReaction`, `sendChannelEdit`, and the private `channelPlane` / `publish` the three senders share |
| `shared/.../concord/ConcordKind.kt` | `ConcordTag.E`; `ConcordTag.K`'s doc now covers both of its uses |
| `shared/.../repository/ConcordRepository.kt` | `sendReaction`, `editMessage` |
| `shared/.../viewmodel/ChannelScreenViewModel.kt` | `sendReaction`, `editMessage`; `reload` compares the whole list, not just its ids |
| `composeApp/.../screens/chat/ChatScreen.kt` | `ReactionToolbar` no longer `private` |
| `composeApp/.../screens/chat/ChatMessage.kt` | `MessageReactions` takes `(emojis, total)` instead of `List`, 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
This is the part that must be byte-exact or nothing interoperates. Freeze it in one file, with the governing spec section quoted above each function.
### 6.1 HKDF info layout (CORD-02 A.1)
```
info = utf8(label) ‖ 0x00 ‖ id[32] ‖ epoch_be[8] // epoch omitted for labels marked "—"
salt = ∅ (zero-length, NOT 32 zero bytes)
len = 32
```
`id` is always present, 32 bytes, all-zeroes where a label has no meaningful id. The epoch is the **only** omittable field.
```kotlin
fun hkdfInfo(label: String, id: ByteArray, epoch: ULong?): ByteArray {
require(id.size == 32)
return Buffer().apply {
writeUtf8(label) // 1. label bytes
writeByte(0) // 2. separator
write(id) // 3. id, always present
if (epoch != null) writeLong(epoch.toLong()) // 4. BE u64, only when the label has an epoch
}.readByteArray()
}
/** HKDF-SHA256. Okio gives us HMAC-SHA256 on every target. */
fun hkdfSha256(
ikm: ByteArray,
info: ByteArray,
salt: ByteArray = ByteArray(0), // Concord always omits it; see note below
length: Int = 32,
): ByteArray {
// Okio refuses a zero-length HMAC key, so substitute HashLen zero octets — the value RFC
// 5869 defines for an absent salt, and one HMAC's block padding makes identical anyway.
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
val prk = ikm.toByteString().hmacSha256(saltKey) // Extract(salt, ikm)
val out = Buffer()
var t = ByteString.EMPTY
var counter = 1
while (out.size < length) { // Expand
t = Buffer().write(t).write(info).writeByte(counter).readByteString()
.hmacSha256(prk)
out.write(t)
counter++
}
return out.readByteArray(length.toLong())
}
```
Two deliberate widenings of the Concord-specialised form, both bought for verification:
1. The general `length` loop (rather than hardcoding 32) unlocks RFC 5869's L=42 and L=82 vectors.
2. The optional `salt` unlocks Cases 1 and 2; Concord always takes the default. Concord ships
no test vectors of its own ("Examples are illustrative, not verifiable test vectors"), so the
RFC is the only ground truth available.
### 6.2 `scalar_normalize` (CORD-02 A.3)
> Must yield a valid secp256k1 secret key: if `seed` is not a valid scalar, append one incrementing counter byte to the hkdf `info` and retry, the counter starting at 0. The reject branch is ~2⁻¹²⁸ rare; the counter keeps it deterministic across implementations.
The counter is appended to the **info**, after whatever fields are present.
### 6.3 `group_key` (CORD-02 A.2)
Split in two on purpose: `groupSeed` is pure byte manipulation and therefore unit-testable,
while `groupKey`'s secp256k1 half needs the SDK, which **cannot load under a host JVM unit
test** (see risk 13). `isValidScalar` replaces the `SecretKey.fromBytes` probe with a plain
big-endian range check against the secp256k1 order, so A.3's retry needs no crypto library.
```kotlin
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
/** group_key up to and including scalar_normalize: returns the normalized seed. */
fun groupSeed(
label: String,
secret: ByteArray,
id: ByteArray,
epoch: ULong? = null,
isValid: (ByteArray) -> Boolean = ::isValidScalar, // seam for A.3's ~2⁻¹²⁸ branch
): ByteArray {
val base = hkdfInfo(label, id, epoch)
var counter = -1 // -1 = no counter byte (first attempt)
while (counter <= 255) {
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
val seed = hkdfSha256(secret, info)
if (isValid(seed)) return seed
counter++ // A.3: counter starts at 0 on first retry
}
error("Concord group_key: scalar_normalize exhausted for label $label")
}
/** A secp256k1 secret key is any integer in [1, n-1]. */
fun isValidScalar(seed: ByteArray): Boolean {
if (seed.size != 32) return false
// reject all-zeroes, then big-endian compare against SECP256K1_ORDER
}
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
return GroupKey(secretKey, Keys(secretKey).publicKey())
}
```
The `conv_key` from A.2 needs **no** separate implementation: `nip44Encrypt(groupSk, groupPk, …)` derives exactly it.
### 6.4 `community_id` (CORD-02 A.4) — note: **no** `0x00` separator
```kotlin
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray =
sha256("concord/community".encodeToByteArray(), ownerXonly, ownerSalt)
```
This is a plain SHA-256 commitment, **not** the HKDF construction.
### 6.5 Labels required in v1
| Label | secret (ikm) | id | epoch |
|---|---|---|---|
| `concord/control` | `community_root` | `community_id` | yes |
| `concord/control-signer` | `control_root` | `community_id` | yes |
| `concord/channel` | `community_root` (public) *or* `channel_key` (private) | `channel_id` | yes |
| `concord/guestbook` | `community_root` | `community_id` | yes |
| `concord/dissolved` | `community_id` | `0…0` | — |
Only `concord/channel`, `concord/guestbook`, and `concord/control` are exercised in v1. `concord/control-signer` is **unused**: it exists to sign Control editions, and nothing in Coop ever writes to the Control plane ([§3.2](#32-deferred--explicitly-out-of-v1)).
### 6.6 Encoding rules (CORD-01, normative)
| Rule | |
|---|---|
| Hex is lowercase | Every 32-byte value in fields and tags is 64 lowercase hex chars |
| Pubkeys are x-only hex, never bech32 | Not `npub`, not 33-byte compressed |
| Tag values are strings | `"4"`, never `4` |
| Empty content is `""` | Never `null`, never omitted |
| `created_at` is unix seconds, **untweaked** | Sub-second ordering rides the `ms` tag |
---
## 7. Wire format — exact stack
### 7.1 Sending a channel message
Implemented in `ConcordPlane.kt`; the whole send path is two calls.
```kotlin
val plane = channelPlaneKey(secret, channelId, epoch) // CORD-03 §1
val rumor = plane.rumor(author, ConcordKind.MESSAGE.toUShort(), text, now)
val wrap = plane.wrap(rumor, signer) // signer = the real author
```
What `wrap` does, in order:
1. **rumor** — `kind` given, stamped with the binding tags and `ms`, `finalizeUnsigned(author).ensureId()`. Never signed.
2. **seal** — `kind` = the plane's `SealForm`. `20013` content is the NIP-44 ciphertext of the rumor JSON; `20014` content is that JSON byte-verbatim. `customCreatedAt(rumor.createdAt())`, then `.finalizeAsync(signer)` so the **real author** signs it and a NIP-46 bunker works.
3. **wrap** — `kind 1059`, content = NIP-44 of the seal JSON, one ephemeral `p` tag, `customCreatedAt(rumor.createdAt())`, `.finalize(Keys(stream.secretKey))` so the **stream key** signs it.
Four things that are easy to get wrong:
- The wrap content is encrypted under the **same conversation key** as the seal. It is double encryption under one key — not a second key, and never the `p`-tagged key.
- `created_at` is **never tweaked**, and the seal and wrap reuse the rumor's value verbatim so all three layers agree. Sub-second ordering lives in the `ms` tag; true time = `created_at * 1000 + ms`.
- The seal form is **owned by the plane**, not passed per call (CORD-02 §5), so a chat wrap can never be sealed with `20014`.
- The NIP-44 65,535-byte plaintext cap is enforced **at build time** before each encryption. A lenient publisher mints events a strict reader cannot decrypt.
### 7.2 Receiving
`PlaneKey.unwrap(event)` runs every check a reader must make and returns null on any failure. Null means **drop**, never retry:
1. `kind` is `1059` — otherwise not ours
2. author equals the plane's stream pubkey — this is what makes CORD-01's write-restricted split real: a read-key holder can verify a wrap but cannot mint one
3. `event.verify()` — id and signature
4. NIP-44 decrypt the wrap content with the **read** key → seal
5. `seal.verify()`, and `seal.kind()` matches the plane's `SealForm` (the other form is a discipline violation, not a variant)
6. `20013` → NIP-44 decrypt the seal content; `20014` → take it verbatim, never re-parsed as an event
7. `UnsignedEvent.fromJson(…).ensureId()`, and `rumor.author() == seal.author()` — NIP-59's impersonation check
8. the **mandatory** CORD-03 §3 binding check (below)
```
channel must be present and strict-equal to the plane's channel_id
epoch must be present and strict-equal to the plane's epoch
```
On a Chat plane both are required, so neither a re-wrap into another Channel nor a cross-epoch replay survives. The community-wide Guestbook and Control planes split no sub-context, so the spec binds nothing there and the check is a no-op.
### 7.3 Seal discipline (CORD-02 §5) — normative
| Plane | Seal kind |
|---|---|
| **Control** | **`20014` plaintext** — a signature over ciphertext could not survive a compaction re-wrap across epochs |
| Chat, Guestbook, rekey | **`20013` encrypted** |
Modelled as `SealForm`, held by `PlaneKey`, set by the plane factories. Because the plane owns it, `wrap` cannot pick the wrong form and `unwrap` rejects the other form outright.
### 7.4 Event kinds used in v1
| Kind | Function | Plane |
|---|---|---|
| `1059` | Stream wrap (durable envelope) | all |
| `21059` | Ephemeral gift wrap — relays MUST NOT store | all (deferred) |
| `20013` | Encrypted seal | Chat, Guestbook, rekey |
| `20014` | Plaintext seal | Control |
| `9` | Message | Chat |
| `3308` | Control edition, sub-kinded by `vsk` | Control |
| `3306` | Join / Leave | Guestbook |
| `3313` | Direct invite (standard NIP-59 wrap, `k`-tagged) | person-to-person |
| `33301` | Public invite bundle | outside the wrap |
| `30078` | *(Coop-internal)* LMDB index bucket for cached rumors | local only |
Full registry: [Appendix A](#appendix-a--concord-constants-reference).
---
## 8. Subscriptions
One subscription id per community, refreshed whenever the relay set or channel set changes:
```kotlin
val id = "concord:${community.idHex}"
client.unsubscribe(id)
// 1. connect the community's relays (from the invite, then the Control fold)
community.relays.forEach {
client.addRelay(RelayUrl.parse(it), RelayCapabilities.read())
client.connectRelay(RelayUrl.parse(it))
}
// 2. one filter per plane; group them so each relay gets the ones it should
val control = Filter().kind(Kind(1059u)).author(controlPk) // signer pk, from the invite
val guestb = Filter().kind(Kind(1059u)).author(guestbook.pk)
val channels = channels.map { Filter().kind(Kind(1059u)).author(it.pk) }
val targets = mutableMapOf>()
community.relays.forEach { url ->
targets[RelayUrl.parse(url)] = listOf(control, guestb) + channels
}
client.subscribe(ReqTarget.manual(targets), id = id)
```
You **cannot** filter on `channel` / `epoch` — they are multi-letter tags and `Filter.customTags` only accepts `SingleLetterTag`. Plane-level filtering by author is the correct granularity anyway (one key per plane), and the `channel` / `epoch` binding is checked post-decrypt.
For paginated history, mirror `MessageManager.getUserMessages`: `Filter().kind(Kind(1059u)).author(planePk).limit(n)` with `until` cursors.
> **Relay compatibility.** Concord wraps reverse NIP-59 (fixed author, ephemeral `p`). Relays enforcing the optional NIP-59 `p`-tag guard will drop them. The bootstrap set (`relay.ditto.pub`, `relay.primal.net`, …) is tuned for NIP-17 — **always use the community's relay set from the invite**, never the app defaults.
---
## 9. Persistence
Two mechanisms, both already established in the codebase.
### 9.1 Roots and keys → `AppStorage.setSecret`
Android Keystore-backed AES-GCM. One JSON blob, mirroring `SettingsRepository`'s single-key pattern exactly.
```kotlin
@Serializable
data class Membership(
val communityId: String, // hex
val owner: String, // hex — proves the community_id
val ownerSalt: String, // hex
val communityRoot: String, // hex 32B — never leave setSecret
val rootEpoch: ULong,
val controlPk: String?, // hex, from invite; trusted-on-trust until a rotation
val controlRoot: String?, // hex — staff only, usually null
val relays: List,
val name: String?,
val channels: List, // id, key(hex), epoch, name, private
)
@Serializable
data class StoredChannelKey(
val id: String, // hex
val key: String, // hex 32B
val epoch: ULong,
val name: String?,
val private: Boolean,
)
```
Storage keys: `concord_memberships` via `getSecret` / `setSecret`.
> **Never use `set()`** for these — it is plaintext DataStore.
### 9.2 Community state → LMDB index events
Mirror `MessageManager.setCachedRumor` precisely. Kind `30078` (`APPLICATION_SPECIFIC_DATA`) is *addressable*, so the `d` tag is what makes each row a unique slot.
```kotlin
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
.tags(listOf(
Tag.identifier(wrapId.toHex()), // `d` — unique slot, and dedupe key
Tag.custom("r", listOf(scopeIdHex)), // index: channel_id or community_id
Tag.custom("k", listOf(rumor.kind().asU16().toString())), // rumor kind
))
.finalizeAsync(Keys.generate()) ?: return // throwaway key, as MessageManager does
client.database().saveEvent(event)
```
Reading a channel:
```kotlin
Filter()
.kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
.reference(channelIdHex)
```
then `UnsignedEvent.fromJson(it.content())`. The Control plane uses the same mechanism with `r = communityIdHex`.
> **Do not skip `Tag.identifier`.** Without a unique `d`, LMDB replaces every message with the previous one sharing the coordinate, silently eating the history. `MessageManager.setCachedRumor` uses the same trick for the same reason.
### 9.3 Control fold (v1, non-gating)
1. Query all stored editions for the community (`r = communityIdHex`).
2. Parse to `ControlEdition { vsk, eid, ev, ep, content, actor }`.
3. Group by `(vsk, eid)`; per group take the **highest `ev`** (refuse to downgrade) whose `ep` chain is intact. Same-version ties break on the **lower rumor id** — never `created_at`.
4. Project:
- `vsk 0` → `CommunityMeta` (name, description, relays, icon, banner, `message_expiration`, `custom`)
- `vsk 2` → `ChannelMeta` (name, `private`, `deleted`, `custom`)
5. Ignore every other `vsk` in v1.
**Not implemented in v1:** authority resolution against the Roster (`vsk 1` / `vsk 3`), `vac` citation validation, and `vsk 4` banlist filtering. See [Risks](#13-risks-and-open-decisions).
### 9.4 Unread badges → memory only
Deliberately the same shape as the chat layer: `Room.unreadCount` is counted in memory, incremented on
an incoming rumor and zeroed by `markAsRead`, and `ChatRepository.refreshChatRooms` explicitly carries
the in-memory value across a reload rather than recomputing it. Concord mirrors that exactly.
- `ConcordChannel.unreadCount` is the model the UI reads, and `ConcordManager.refreshPlanes` merges it
from an in-memory `Map` so a Control edition cannot silently clear every badge.
- A message is counted only when it is a `kind 9` on a plane we hold, authored by someone other than us
— our own wraps come back through the same subscription, and a re-fetch arrives several times.
- It does **not** survive a restart. Neither does a DM's. Persisting it would mean a read-marker store
(a new key, a new write path) for a badge, which is not worth it in v1.
### 9.5 Message freshness → `revision`
`channelMessages` is an on-demand LMDB query, and nothing in the routing branch told a screen that a
message had arrived. `ConcordManager.revision` is that signal: a `StateFlow` counter bumped on
every cached rumor and every local send, which a screen combines with its Channel id to re-query. A
counter rather than a set of changed scope ids, because two messages in one Channel must both be
observable. It is one value for the whole manager rather than a flow per Channel, so nothing has to be
created, subscribed and disposed per room.
---
## 10. Invites
### 10.1 Direct Invite — kind `3313`
Rides a **standard** NIP-59 wrap, so it arrives through the existing giftwrap pipeline and only needs an interception in `handleNotifications`:
| Layer | Kind | Details |
|---|---|---|
| Wrap | `1059` | ephemeral single-use author, `["p", ""]`, `["k", "3313"]`, optional NIP-40 `["expiration", ""]` |
| Seal | `13` | standard NIP-59 — **not** the reversed stream wrap |
| Rumor | `3313` | `content` = the `CommunityInvite` JSON, `tags: []` |
Indexed fetch, if ever needed: `{"kinds":[1059], "#p":[""], "#k":["3313"]}`.
> The `k` tag is unsigned relay-visible bytes — a hint, never authority. An invite is whatever *unwraps* to a kind `3313` rumor, so accept an untagged one too.
### 10.2 Public link — `$BASE/invite/#`
| Part | Contents |
|---|---|
| `naddr` | NIP-19 addressable pointer for `(kind 33301, link_signer, "")` — a **locator**, not a secret |
| `fragment` | base64url (unpadded) of `[version][flags][relays?][token:16]` — **never sent to any server** |
| `version` | must be `4`; reject lower as legacy |
| `flags` | stock-set bit → zero relay bytes follow |
| relay encoding | `1..254` = dictionary id, `0` = `[len][host]` wss-implied, `255` = `[len][full URL]` |
| max bootstrap relays | 3 |
Stock dictionary: `1` = `wss://jskitty.com/nostr`, `2` = `wss://asia.vectorapp.io/nostr`, `3` = `wss://relay.ditto.pub`, `4` = `wss://relay.dreamith.to`.
`bundle_key = hkdf(token, "concord/invite-key")` — the token derives exactly this one thing.
### 10.3 Bundle (`CommunityInvite`) and its validation
```jsonc
{
"community_id": "",
"owner": "",
"owner_salt": "", // verify: community_id == sha256("concord/community" ‖ owner ‖ salt)
"community_root": "",
"root_epoch": 0,
"control_pk": "", // taken on trust — nothing in the bundle can prove it
"channels": [ { "id": "", "key": "", "epoch": 1, "name": "testers" } ],
"relays": ["wss://…"],
"name": "Vector",
"icon": { "url": "…", "key": "…", "nonce": "…", "hash": "…" },
"expires_at": 1735689600000, // optional, unix MILLISECONDS
"creator_npub": "", // optional attribution
"label": "Reddit" // optional attribution
}
```
**Required validation, in order:**
1. `community_id == sha256("concord/community" ‖ owner ‖ owner_salt)` — refuse the bundle otherwise.
2. Reject more than a sane channel count (spec's reference ceiling is 256).
3. Truncate `relays` to the Community's cap (5 recommended).
4. If `expires_at` is in the past: preview still renders, **joining refuses**.
### 10.4 Join flow
1. Parse link → decode fragment → require `version == 4`.
2. Derive `bundle_key`.
3. Fetch coordinate `(33301, link_signer, "")` from the bootstrap relays.
4. Check liveness — a `["vsk","9"]` tombstone at the coordinate replaces the bundle.
5. Decrypt content → `CommunityInvite`.
6. Validate per §10.3.
7. **Preview only.** No join, no subscribe, no icon fetch, no presence.
8. On explicit accept: persist `Membership` → connect relays → subscribe → publish Guestbook `join`.
```jsonc
// kind 3306
{ "kind": 3306, "pubkey": "", "content": "join",
"tags": [ ["ms", "128"], ["invite", "", "