diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..fdf9f39
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,1004 @@
+# 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).
+
+---
+
+## 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. [Test plan](#14-test-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` |
+| **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 |
+|---|---|
+| **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 / Edits / Threads / WebXDC | Not needed for a first cut. |
+| Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. |
+| 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 |
+| `ConcordCrypto.kt` | `hkdfSha256`, `hkdfInfo`, `groupKey`, `communityId`, `editionHash`, `prevCommit`, `scalarNormalize`, hex/bytes helpers |
+| `ConcordModels.kt` | `Membership`, `CommunityInvite`, `CommunityMeta`, `ChannelMeta`, `ConcordChannel`, `ConcordMessage`, `ControlEdition` |
+| `ConcordPlane.kt` | `PlaneKeys` (sk/pk/relays), `wrap()`, `unwrap()`, rumor builders — the CORD-01 stack |
+| `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/#` decoder, relay dictionary |
+| `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/`
+
+| File | Contents |
+|---|---|
+| `Community.kt` | UI-facing models + derived flows, mirroring `Room.kt` / `RoomUiState` |
+| `repository/ConcordRepository.kt` | `ErrorHost by createErrorHost()`, `MutableStateFlow`, `stateIn(scope, WhileSubscribed(5000), …)` |
+| `viewmodel/ConcordViewModel.kt` | Façade over the repository, mirrors `ChatViewModel` |
+| `viewmodel/ChannelScreenViewModel.kt` | Entry-scoped, mirrors `ChatScreenViewModel` (`mutableStateListOf`) |
+
+### 5.3 New — `composeApp/src/androidMain/kotlin/su/reya/coop/screens/`
+
+| File | Contents |
+|---|---|
+| `CommunitiesScreen.kt` | Joined communities list + FAB → Join |
+| `CommunityScreen.kt` | Channel list for one community |
+| `JoinCommunityScreen.kt` | Paste link / scan QR → preview → Join |
+| `communities/CommunityComponents.kt` | `ChannelRow`, `CommunityRow`, `ChannelInput`, empty states |
+
+### 5.4 New — `shared/src/commonTest/kotlin/su/reya/coop/`
+
+| File | Contents |
+|---|---|
+| `ConcordCryptoTest.kt` | RFC 5869 HKDF vectors, info-layout golden bytes, `community_id` self-certification, `group_key` determinism |
+| `ConcordPlaneTest.kt` | wrap→unwrap round trip, impersonation rejection, `channel`/`epoch` mismatch rejection |
+
+### 5.5 Modified (6 files, all small)
+
+| File | Change |
+|---|---|
+| `shared/.../nostr/Nostr.kt` | add `val concord`, 2 routing branches |
+| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params |
+| `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector |
+| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` |
+| `composeApp/.../screens/HomeScreen.kt` | one entry in `BottomMenuList` |
+| `composeApp/src/androidMain/composeResources/drawable/ic_communities.xml` | icon |
+
+---
+
+## 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, salt = ∅. Okio gives us HMAC-SHA256 on every target. */
+fun hkdfSha256(ikm: ByteArray, info: ByteArray, length: Int = 32): ByteArray {
+ val prk = ikm.toByteString().hmacSha256(ByteString.EMPTY) // 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())
+}
+```
+
+Implementing the general `length` loop (rather than hardcoding 32) costs three lines and buys RFC 5869's L=42 test vectors — the only real verification available, since Concord ships no test vectors ("Examples are illustrative, not verifiable test vectors").
+
+### 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)
+
+```kotlin
+data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
+
+fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong?): GroupKey {
+ 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)
+ val sk = runCatching { SecretKey.fromBytes(seed) }.getOrNull() // the reject branch
+ if (sk != null) return GroupKey(sk, Keys(sk).publicKey())
+ counter++ // A.3: counter starts at 0 on first retry
+ }
+ error("scalar_normalize exhausted")
+}
+```
+
+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 needed only if community creation is added ([M4.5](#m45--community-creation-optional)).
+
+### 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
+
+```kotlin
+suspend fun sendChannelMessage(ch: ConcordChannel, text: String) {
+ val author = signer.getPublicKeyAsync() ?: error("not signed in")
+ val now = Clock.System.now()
+ val secs = now.epochSeconds.toULong()
+ val ms = (now.toEpochMilliseconds() % 1000).toInt()
+
+ // 1. rumor — kind 9, NEVER signed, MUST carry the binding tags
+ val rumor = EventBuilder(Kind(9u), text)
+ .tags(listOf(
+ Tag.custom("channel", listOf(ch.idHex)),
+ Tag.custom("epoch", listOf(ch.epoch.toString())),
+ Tag.custom("ms", listOf(ms.toString())),
+ ))
+ .finalizeUnsigned(author)
+ .ensureId()
+
+ // 2. seal — kind 20013, signed by the REAL author (works with a bunker signer)
+ val seal = EventBuilder(
+ Kind(20013u),
+ nip44Encrypt(ch.sk, ch.pk, rumor.asJson(), Nip44Version.V2) // self-ECDH conv key
+ ).finalizeAsync(signer) ?: error("seal failed")
+
+ // 3. wrap — kind 1059, signed by the STREAM key, ephemeral `p` (NIP-59 reversed)
+ val wrap = EventBuilder(
+ Kind(1059u),
+ nip44Encrypt(ch.sk, ch.pk, seal.asJson(), Nip44Version.V2) // same conv key, second layer
+ )
+ .tags(listOf(Tag.publicKey(Keys.generate().publicKey()))) // ephemeral; discard the secret
+ .finalize(Keys(ch.secretKey)) // stream signature
+
+ client.sendEvent(wrap, SendEventTarget.to(communityRelays), AckPolicy.none())
+}
+```
+
+Two 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.
+- `created_at` is **never tweaked**. Sub-second ordering lives in the `ms` tag; true time = `created_at * 1000 + ms`.
+
+### 7.2 Receiving
+
+```kotlin
+fun unwrap(event: Event, keys: PlaneKeys): UnsignedEvent? {
+ val seal = Event.fromJson(nip44Decrypt(keys.sk, keys.pk, event.content()))
+ if (!seal.verify()) return null
+ val rumorJson = when (seal.kind().asU16()) {
+ 20013u -> nip44Decrypt(keys.sk, keys.pk, seal.content()) // Chat / Guestbook / rekey
+ 20014u -> seal.content() // Control only, byte-verbatim
+ else -> return null
+ }
+ val rumor = UnsignedEvent.fromJson(rumorJson).ensureId()
+ if (rumor.author() != seal.author()) return null // NIP-59 impersonation check
+ return rumor
+}
+```
+
+Then the **mandatory** CORD-03 §3 binding check — drop on mismatch, never render:
+
+```kotlin
+val tChannel = rumor.tags().toVec().firstOrNull { it.kind() == "channel" }?.content()
+val tEpoch = rumor.tags().toVec().firstOrNull { it.kind() == "epoch" }?.content()
+if (tChannel != ch.idHex || tEpoch != ch.epoch.toString()) return // re-wrap / cross-epoch replay
+```
+
+### 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** |
+
+Enforce the NIP-44 65,535-byte plaintext cap yourself at **every** layer before publishing. Libraries are lenient; a lenient publisher mints events a strict reader cannot decrypt.
+
+### 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).
+
+---
+
+## 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", "", "