Files
coop-mobile/PLAN.md
T
2026-09-15 15:17:14 +07:00

1268 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)`<br>`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<String>)`, `tag.kind()`, `tag.asVec()` | Filters only support **single-letter** tags |
| Subscribe / publish to specific relays | `client.subscribe(ReqTarget.manual(mapOf(relay to filters)), id)`<br>`client.addRelay(url, RelayCapabilities.read())`<br>`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`) | 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. 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/<naddr>#<fragment>` 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/`
**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. M1M4 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<ReactionGroup>`, so Channels can share it |
| `composeApp/.../screens/communities/ChannelScreen.kt` | long-press action row, reaction chips, the Edit flow and its banner |
---
## 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<RelayUrl, List<Filter>>()
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<String>,
val name: String?,
val channels: List<StoredChannelKey>, // 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<channelIdHex, Int>` 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<Long>` 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", "<recipient>"]`, `["k", "3313"]`, optional NIP-40 `["expiration", "<secs>"]` |
| 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":["<me>"], "#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/<naddr>#<fragment>`
| 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": "<hex>",
"owner": "<hex>",
"owner_salt": "<hex>", // verify: community_id == sha256("concord/community" ‖ owner ‖ salt)
"community_root": "<hex>",
"root_epoch": 0,
"control_pk": "<hex>", // taken on trust — nothing in the bundle can prove it
"channels": [ { "id": "<hex>", "key": "<hex>", "epoch": 1, "name": "testers" } ],
"relays": ["wss://…"],
"name": "Vector",
"icon": { "url": "…", "key": "…", "nonce": "…", "hash": "…" },
"expires_at": 1735689600000, // optional, unix MILLISECONDS
"creator_npub": "<hex>", // 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": "<member>", "content": "join",
"tags": [ ["ms", "128"], ["invite", "<creator hex>", "<label>"] ] } // invite tag optional
```
> **`expires_at` unit trap.** The bundle uses unix **milliseconds**; the Invite List entry (`13303`) uses unix **seconds**; the NIP-40 tag uses **seconds**. Three representations of the same instant. Easy silent bug — keep the conversions in one function.
### 10.5 Deep links
Concord invite links are `https://<base>/invite/<naddr>#<fragment>`. The fragment never reaches a server, and Android intent filters strip fragments unreliably — so **paste-text and QR scan are the sanctioned paths**. Do not build an https deep-link filter.
If a deep link is wanted later, add `coop://invite?url=<urlencoded>` and let Coop's own handler carry the fragment.
---
## 11. UI
Mirrors the DM screens structurally so it feels native to Coop. **Delivered in M5** — see
[M5](#m5--ui--done) for what actually shipped and what did not.
| Screen | Mirrors | Notes |
|---|---|---|
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community name and channel count, plus Direct Invite cards above the list. **No avatar** for a Community: its icon is an encrypted blob v1 never fetches, so the row shows the placeholder rather than a picture it does not have. Empty state per `ContactListScreen` convention. |
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock on private, `No key` on one we cannot read. `#general` arrives with the community's genesis metadata. Carries the beta notice from §11.2. |
| `ChannelScreen` | `ChatScreen` | Reuses `DateSeparator` and `ChatInput`, plus `ReactionToolbar` and `MessageReactions` since M6. Discord-style (author shown per message) rather than Coop's DM style. The input is replaced by a line of text when the Channel has no key here. **M6:** long-press for the emoji row, an Edit action on your own messages, chips under any message that has reactions. |
| `JoinCommunityScreen` | `NewChatScreen` | Paste link or QR scan (reuse `LocalScanResult` / `Screen.Scan`), preview card, Join button. |
**Deviation:** `ChannelScreen` is not in the §5.3 file list even though this table always implied it — the file plan simply missed it.
### 11.1 Wiring, following existing conventions
```kotlin
// MainActivity.kt — lazy field next to chatRepository
private val concordRepository by lazy {
ConcordRepository(NostrManager.instance, AppStore(this@MainActivity), scope)
}
// App.kt — factory branch
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(concordRepository)
```
Plus, in `App.kt`:
- `val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)` (activity-scoped)
- `entry<Screen.Communities> { CommunitiesScreen(concordViewModel) }`
- `entry<Screen.Community> { key -> … }` (entry-scoped `CommunityScreenViewModel`)
- `entry<Screen.Channel> { key -> … }` (entry-scoped `ChannelScreenViewModel`, keyed like `Screen.Chat`)
- a fourth `launch { concordViewModel.errorEvents.collect { snackbarHostState.showSnackbar(it) } }` in the existing `LaunchedEffect` at `App.kt:162-178`
And one entry in `BottomMenuList` (`HomeScreen.kt:785-791`) plus `ic_communities.xml` in `composeApp/src/androidMain/composeResources/drawable/`.
### 11.2 UI honesty
- Label the feature **beta** and surface the "no authority enforcement yet" limitation in a community info row. Delivered as `BetaNotice` on `CommunityScreen`.
- Be explicit that Coop **joins** communities and never creates one ([§3.2](#32-deferred--explicitly-out-of-v1)). Nothing in the UI implies an owner-side flow exists, because there is no owner key here to hold: there is no create action anywhere, and `CommunitiesScreen`'s empty state says the only way in is an invite link.
---
## 12. Milestones
Each milestone ends with something runnable.
### M1 — Crypto core (no UI, no networking) — **done**
`ConcordCrypto.kt` + `ConcordKind.kt`. Committed as `e221eda "add concord crypto"`.
**Verified** with RFC 5869 Test Cases 13 and SHA-256 digests computed outside the codebase, then cross-checked against the SDK on the iOS target (`isValidScalar` vs `SecretKey.fromBytes`, `groupKey``pk == xonly(sk)`, NIP-44 self-ECDH round trip). Those tests were not kept — see §14.
- RFC 5869 Test Cases 1, 2 & 3 pass ✅
- `hkdfInfo` matches a golden hex annotated with the A.1 layout ✅
- `communityId` / `prevCommit` / `editionHash` match digests computed outside the codebase ✅
- `groupSeed` is deterministic, separates label/id/epoch/secret, and retries with the A.3 counter ✅
- `isValidScalar` agrees with the secp256k1 range at 0, 1, `n-1`, `n`, above `n`
- `groupKey``pk == xonly(sk)`
### M2 — Plane stack (no networking) — **done**
`ConcordPlane.kt`. Delivers `SealForm`, `ChatBinding`, `PlaneKey` + the three plane factories, `PlaneKey.rumor()`, `PlaneKey.wrap()` and `PlaneKey.unwrap()`.
**Verified** with a throwaway end-to-end check run on the iOS target, then deleted (no test files are kept):
- build a `kind 9` message for a channel → wrap → unwrap → **byte-identical** rumor JSON, with `channel` / `epoch` / `ms` intact and `created_at` shared by all three layers ✅
- the wrap carries exactly one ephemeral `p` tag ✅
- a different channel id, a different epoch, or the Guestbook plane cannot decrypt it ✅
- a `channel` or `epoch` mismatch, and a rumor with no binding tags at all, are dropped ✅
- publishing with a signer that is not the rumor's author is refused, and a hand-built wrap whose seal author differs is dropped on read ✅
- `20014` is rejected on a Chat plane, and `20013` is rejected on the Control plane ✅
- the Control plane is readable from the **read** key plus the writers' pubkey alone, refuses to wrap, and drops a wrap signed by anyone but staff ✅
### M3 — Join + read
Delivered. `ConcordModels.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordStore.kt`, the `ConcordManager` read path, and the two routing branches in `Nostr.kt`.
**Done when:** pasting a real invite link stores the membership, connects the community's relays, folds metadata + channel list, and renders channel messages from a real community.
What shipped:
- `ConcordInvite.kt``parseInviteLink`, `decodeInviteFragment` (CORD-05 §3), the stock relay dictionary, `inviteBundleKey`, `decryptInviteBundle`, `problems()` validation, `relaySet`, `toMembership`
- `ConcordControl.kt``editionOf` + `fold`, with the `ep` chain walk, highest-version-wins and the lower-rumor-id tie-break
- `ConcordStore.kt` — memberships through `AppStorage.setSecret`, plane rumors through LMDB indexed on `d`/`r`/`k`
- `ConcordManager.kt` — plane index, `restore()`/`sync()`, `isPlaneAddress`, `handlePlaneEvent`, `onInboxRumor`, `previewInvite`, `join`, `channelMessages`
- `Nostr.kt` — the plane-author routing branch, the Concord queue, the Direct Invite interception, and `concord.attach(storage)` from `init`
**Verified** with a throwaway check on the iOS target (20 cases, all passing), then deleted:
- the fragment decodes for the stock flag, explicit dictionary ids, `wss://`-implied hosts and verbatim URLs; unknown ids, short tokens and a wrong version are refused; explicit entries cap at 3 while the stock flag yields the whole dictionary ✅
- a link round-trips through a real `naddr` (kind `33301`, empty identifier) ✅
- a valid bundle passes `problems()`; a tampered owner, a malformed salt and 257 channels are refused ✅
- `bundle_key` round-trips a bundle through NIP-44, and a different token cannot open it ✅
- `expires_at` converts ms→s and an absent expiry never expires ✅
- an intact Control chain folds to its highest version; a broken link truncates to the last good one; a missing predecessor disqualifies that version alone; same-version ties break on the lower rumor id; entities fold independently; metadata for another `community_id` is ignored; `deleted` is projected ✅
- `editionOf` parses a real rumor and refuses `vsk 10` and a missing `ev`
**One bug the check caught:** `inviteBundleKey` first used the 32-byte hex guard, but the token is 16 bytes — every link would have failed to open.
**Not covered by that check:** anything needing the network — fetching a bundle, the subscription, and publishing the Join. Those are the interop smoke test below.
### M3 interop smoke test — the acceptance gate
Against a real Community, with a real invite link:
1. paste the link → the preview shows the right name and channel count, and no `problems`
2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list
3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently
4. a message sent from Coop appears there too — the M4 half of the same gate
5. a reaction from that client groups under the message here, and one sent from Coop groups there — M6, and the same silent-failure shape as step 3
6. an edit from that client replaces the text here with `(edited)` beside it — M6, and the *least* certain of the six, because `examples.md` calls the edit shape illustrative
If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
If step 4 fails while step 3 passes, the read path is right and the fault is in the publish: the relay set, or the relay dropping these wraps (risk 4).
### M4 — Write — **done**
`ConcordManager.sendChannelMessage`, `unreadCount` / `markChannelRead`, and the `revision` signal a Channel screen re-queries on.
What shipped:
- `sendChannelMessage(channelIdHex, content)` — the send *is* `PlaneKey.rumor` + `PlaneKey.wrap`, so there is no second encrypter to keep in sync with the read path. The rumor is cached locally **before** publishing, so the message is visible even with every relay down, and the subscription's echo of the wrap lands on the same `d` slot instead of duplicating the message. Throws when the Channel is one we hold no key for — a Private Channel is listable without being writable.
- `unreadCount` / `markChannelRead` and `ConcordChannel.unreadCount` (§9.4), counted from the routing branch rather than from a re-read, so a badge costs no LMDB query per message.
- `revision: StateFlow<Long>` (§9.5), so a Channel screen can tell that something arrived.
**Verified** with a throwaway check on the iOS target (2 cases, all passing), then deleted:
- a message delivered on a Channel plane is routed and raises that Channel's badge — in the map and in `ConcordChannel.unreadCount` — while our own message and a `kind 7` reaction raise nothing ✅
- `markChannelRead` clears the badge and republishes it, and a re-index carries the badge across rather than resetting it ✅
- a sent rumor carries exactly `channel` / `epoch` / `ms` at `kind 9`, and the Guestbook and a different Channel both refuse to read it ✅
**Not covered, and not coverable offline:** `sendChannelMessage` needs a live `client`, so the publish itself — relay selection, the ack policy, the relay's echo landing on the same `d` — is exercised only by the interop smoke test. The check above used a `MemoryStorage` fake, so the real Android Keystore path is untouched by it too.
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop. **This is a network criterion and has not been run** — see §14.
### M4.5 — Community creation — **out of scope**
Removed from the plan: mobile does not create communities. An owner mints `community_id`,
`community_root`, `control_root` and the genesis editions once, from a desktop or reference client,
and Coop's job is to join what that produces. The full rationale is in
[§3.2](#32-deferred--explicitly-out-of-v1).
This was previously listed as "optional, ~150 lines on top of M3". The estimate was probably right,
and it was genuinely tempting — it is the only way to exercise the Control-plane **write** path, and
the only way to demo the feature without a second client. It is still the wrong shape for this app.
### M5 — UI — **done**
`ConcordRepository` / view models / 5 screens / `BottomMenuList` entry / 2 icons / error snackbars.
**Done when:** the whole flow works without adb logcat.
What shipped:
- **`ConcordRepository`** — flows forwarded from the manager, and one `attempt` funnel so no screen sees an exception. There is no state of its own: `ConcordManager` already pushes the read model, so a copy here would only be something to keep in sync.
- **`ConcordViewModel`** (activity-scoped) and **`ChannelScreenViewModel`** (entry-scoped). The Channel one re-reads on `revision` rather than on an event, cancels any in-flight read so a burst of revisions cannot let an older snapshot land last, and only replaces the list when the ids actually changed — so a message in *another* community costs one indexed query and no recomposition.
- **5 screens** in `screens/communities/`, mirroring `HomeScreen` / `ContactListScreen` / `ChatScreen` / `NewChatScreen` row for row.
- **Reused, not reimplemented:** `ChatInput` and `DateSeparator` from `screens/chat`. `ChatInput`'s `onUpload` / `onMicClick` became nullable — Concord has neither in v1, and two live-looking buttons that do nothing would be worse than their absence.
- **Two small backend additions the UI genuinely needed:** `restored`, because an empty membership list means the same thing before and after `restore()`; and `reset` / `clearMemberships`, because membership keys are identity-scoped and must not survive a logout (risk 17).
- **Logout now drops Concord state.** `HomeScreen`'s logout path calls `resetInternalState()` alongside the account and chat ones.
- **QR scan routes invite links.** `HomeScreen` checks `parseInviteLink` before `PublicKey.parse`, so scanning an invite lands on the Join screen; `JoinCommunityScreen` also reads `LocalScanResult` when reached from the Communities FAB.
**Verified:** see §14. The UI itself was verified by **compilation on both targets**`:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` — and by nothing else. **No screen has been run.** `adb`/emulator is the only way to exercise the layout, the nav routes and the snackbar collector, and that has not been done.
### M5.5 — not done, and worth knowing
| Gap | Why it is not there |
|---|---|
| **No refresh / retry.** `sync()` is only called at startup and on join. A relay that refuses a subscription at startup is not retried until the app restarts. | Adding a button would not help: `sync()` only re-subscribes, it does not backfill. A real retry needs `fetchEvents`-based backfill on the plane addresses, which is a backend change |
| **No message backfill.** History is whatever LMDB cached while subscribed. | The same missing `fetchEvents` path |
| **No replies, attachments, threads, deletes.** | Reactions and edits landed in M6 (§12); the rest are not in v1. |
| **No `@Preview`.** | None exist in the repo (B.8) |
| **No deep link for `https://…/invite/…`.** | Would need an intent filter in the manifest; `coop://` handling stays as-is |
### M6 — Reactions and edits — **done**
`kind 7` reactions and `kind 3302` edits, reusing the DM reaction UI. Threads (`kind 1111`) are still
more work — separate milestone.
What shipped:
- **One write path, three kinds.** `sendChannelMessage` / `sendChannelReaction` / `sendChannelEdit`
are all `plane.key.rumor` + `plane.key.wrap` behind one private `publish(plane, kind, content,
extraTags)`. There is no second encrypter to keep in sync with the read path — the extra tags are
the only thing that differs, and they are the only thing the read fold looks at.
- **Tags exactly as `examples.md` §2.3 / §2.5 has them**, which is the whole interop risk here:
a reaction is `e` (target **rumor** id, never the wrap's) + `p` (target's author) + `k` `"9"`;
an edit is `e` alone. Both ride the ordinary encrypted seal at the Channel address, so an edit or
reaction reaches exactly the readers the original message did.
- **The fold is in `toChannelMessages()`**, next to `toConcordMessage()`. Messages are projected
first, then reactions and edits are folded on by target id. An edit is applied only when its author
matches the message's — the `e` tag is a claim, the seal's author is the proof — and the latest
edit by `timestampMs` wins. A reaction for a message we no longer hold is dropped with it.
- **`ReactionToolbar` and `MessageReactions` are now shared**, not DM-only. `MessageReactions` lost
its `ReactionGroup` parameter and takes `(emojis, total)`, which is all it ever drew — the same
shape of change M5 made to `ChatInput` when Concord had no upload or mic. `ReactionToolbar` is
unchanged, just no longer `private`.
- **Long-press a message** for the emoji row; an Edit action appears on your own messages, which
puts the replacement text in the input under an "Editing a message" banner. Tap anywhere to
dismiss. This is an inline row rather than the DM screen's floating toolbar — the DM one is
anchored with window coordinates and a backdrop, and none of that buys anything in a plain
`LazyColumn`.
- **A latent M5 bug, fixed:** `ChannelScreenViewModel.reload` compared only message *ids*, so a
reaction or an edit — which change a message without changing which messages there are — would
have been fetched and then thrown away. It compares the whole list now.
**Verified:** compilation on both targets, as M5 was — `:shared:compileKotlinIosSimulatorArm64`,
`:shared:compileDebugKotlinAndroid` and `:composeApp:compileDebugKotlinAndroid`, with the recompiled
`composeApp` classes confirmed by timestamp (a cached `BUILD SUCCESSFUL in <1s` on `:composeApp`
proves nothing — §14.1).
**Not verified, and worth saying out loud:** this is the first milestone whose *entire* output is a
wire format nothing in this repository can check. `examples.md` marks the edit shape explicitly
illustrative ("The CORDs register the kind but don't yet pin its fields"), and a wrong `e`-tag or
`k`-tag convention fails silently — the reaction simply never groups. The interop smoke test below
gains two more steps because of it.
---
## 13. Risks and open decisions
### 13.1 Spec conflicts — resolve before writing byte-exact code
| # | Conflict | Resolution |
|---|---|---|
| 1 | **`community_id` owner proof.** CORD-05 §1 writes `sha256(owner ‖ salt)`. `examples.md` §6.1 writes `sha256("concord/community" ‖ owner ‖ salt)`. CORD-02 A.4 (marked *frozen*, normative) writes `sha256(utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32])`. | **Implement A.4.** It is a hard-fail interop check, so validate against a reference implementation at the first opportunity. |
| 2 | **`expires_at` units differ in three places** — bundle = unix **ms**, Invite List entry = unix **s**, NIP-40 tag = **s**. | Keep all conversions in one function. |
| 3 | **`vac` on `3303`.** CORD-06 §3 says a rotation cites its Grant, but neither its §1 JSONC nor `examples.md` shows the tag. | Deferring rekeys sidesteps this. |
| 4 | **What `concord/invite-key` yields.** CORD-05 §2 writes `bundle_key = hkdf(token, "concord/invite-key")` and then `nip44_encrypt(bundle_key, …)`, which reads as a raw conversation key. A.6 lists the label in the *derivation* registry, where every row is fed through `group_key``scalar_normalize` → keypair, and elsewhere CORD-01 always writes `conv_key` for the self-ECDH value it feeds `nip44_encrypt`. | **Modelled as `group_key("concord/invite-key", token, 0…0)`.** This is the only reading the existing SDK can implement — it exposes NIP-44 by keypair only, never by conversation key — and it is consistent with every other row in A.6. It is a hard-fail interop check: verify at the smoke test, and if a bundle refuses to open, the raw-conversation-key reading is the alternative and would need NIP-44 hand-rolled. |
### 13.2 Design risks
| # | Risk | Mitigation |
|---|---|---|
| 4 | **Relay compatibility.** Reversed NIP-59 wraps may be dropped by relays enforcing the `p`-tag guard. | Always use the community's relay set from the invite. Never the app defaults. |
| 5 | **No owner recovery, by design.** `community_id` commits to the owner's key; lose it and the community is dead. | Not a risk Coop can mitigate — it never holds an owner key, because it never creates a community. Worth stating on the Join screen so nobody expects a rescue path. |
| 6 | **`control_pk` is taken on trust** at join — nothing in the invite can prove it. | Build nothing security-relevant on it beyond the subscription address. |
| 7 | **v1 has no authority enforcement.** Staff hold `control_root`, so blast radius is limited, but a hostile staffer can forge metadata/channels. | Document in code; label beta in UI; close with a CORD-04 milestone. |
| 8 | **One notification pump.** A second `client.notifications()` consumer would silently split subscriptions. | Comment on `handleNotifications` stating it must stay the only consumer. |
| 9 | **LMDB replaces addressable events by `d`.** Would silently eat messages. | Always set `Tag.identifier(wrapId)`. |
| 10 | **No succession ↔ no honest rollback.** Dissolution and refoundings are one-way. | Do not build UI implying otherwise. |
| 11 | **Spec is young** (71 commits, no reference implementation in-repo, examples explicitly non-normative). | Keep every frozen constant in `ConcordKind.kt` so a spec revision is a one-file change. |
| 12 | **`scalar_normalize`'s retry branch is ~2⁻¹²⁸ rare.** It will never fire in practice, so a bug there would never surface either. | Split the pure `groupSeed` out of `groupKey` so the counter path is reachable through its `isValid` seam rather than buried behind a crypto call. |
| 13 | **The nostr SDK cannot run in a host JVM unit test.** Its uniffi bindings are JNA-backed and the Android artifact carries Android-ABI `.so` files, so `testDebugUnitTest` on macOS fails with `UnsatisfiedLinkError: libjnidispatch.jnilib`. Discovered in M1. | Not a problem while tests are not kept, but it does mean **there is no automated regression net** for anything that touches `SecretKey`, `Keys`, `nip44*` or `EventBuilder`. Verification is manual: `:shared:iosSimulatorArm64Test` is the only executable target here that can load the SDK, so a throwaway check in `shared/src/iosTest` is the cheapest way to exercise wire-format code, and the M3 interop smoke test is the real acceptance gate. Keep the SDK-free half in pure functions so it at least *could* be covered without a device. |
| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. |
| 15 | **Unread badges live in memory only**, so a restart clears every one of them (§9.4). | This is the DM path's behaviour too (`Room.unreadCount` is likewise in-memory). Persisting it means a read-marker store, a new key and a new write path, all for a badge — worth doing only when a user asks for it. |
| 16 | **The write path has no offline check.** `sendChannelMessage` needs a live `client`, so nothing kept in the repo exercises a publish, and the `MemoryStorage` fake used by the M4 check never touches Android Keystore. | Acceptance is interop smoke test step 4. Read and write share `PlaneKey.wrap`, and reading is verified independently (M2/M3), so a send-only failure localises to the publish: the relay set, or a relay dropping the wrap (risk 4). |
| 17 | **Logout had to be taught about Concord.** `AccountRepository.logout` cleared the signer and wiped LMDB but nothing else, so the membership blob — which *is* the keys to every Community (CORD-02 §2) — would have survived into the next identity on the same device, silently granting it seats it never took. | `ConcordManager.reset()` clears the storage key, drops the subscriptions and re-indexes to nothing; `HomeScreen`'s logout path calls it. **Interactive confirmation is out of reach**`logout` is not suspend and the reset is launched from it — so what is verified is that the call path exists and compiles, not that a logout actually erased the blob on a device. |
| 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*. |
### 13.3 Closed decision
**Community creation: out of scope.** Coop joins communities and never creates one. Rationale in
[§3.2](#32-deferred--explicitly-out-of-v1). Consequences: `concord/control-signer` stays unused, the
Control-plane write path is never exercised, and there is no way to demo the feature without a real
community minted elsewhere — which is the same dependency the interop smoke test already has.
---
## 14. Verification plan
**No test files are kept** (decision, M2). The checks below were written and run during M1M6,
then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. They are
recorded here (and per-milestone in §12) because they are what establishes the wire format is
right, and because whoever next touches this code should re-run the same checks.
The problem this leaves is real and worth stating plainly: this is frozen-by-spec crypto with
**no spec test vectors** ("Examples are illustrative, not verifiable test vectors"), where a
byte-layout mistake breaks interop *silently* rather than failing loudly. With no kept suite
there is no regression net, so:
- **The M3 interop smoke test is the acceptance gate.** Nothing else catches a label typo.
- **`:shared:iosSimulatorArm64Test` is the only target here that can load the SDK** (risk 13), so
a throwaway check in `shared/src/iosTest` — written, run, deleted — is the cheapest way to
exercise anything touching `SecretKey`, `Keys`, `nip44*` or `EventBuilder` while developing.
- Anything SDK-free is deliberately kept in a pure function so it *could* be covered from
`commonTest` (JVM and iOS, no device needed) if this decision is ever revisited.
### 14.1 Checks that were run
| Check | Type | Ran on |
|---|---|---|
| RFC 5869 TC1 / TC2 / TC3 | known-answer | host JVM + iOS ✅ M1 |
| `hkdfSha256` rejects L > 255·32 | boundary | host JVM + iOS ✅ M1 |
| `hkdfInfo(channel, id32, 4u)` / epoch-omitted length | golden hex, annotated with the A.1 layout | host JVM + iOS ✅ M1 |
| `communityId`, `prevCommit`, `editionHash` | golden SHA-256 computed outside the codebase | host JVM + iOS ✅ M1 |
| `editionHash` absent-vs-zero-`prev` | negative | host JVM + iOS ✅ M1 |
| `groupSeed` determinism + label/id/epoch/secret separation | property | host JVM + iOS ✅ M1 |
| `groupSeed` counter path (counter appended to `info`, starts at 0) | injected failing seed | host JVM + iOS ✅ M1 |
| `isValidScalar` at 0, 1, `n-1`, `n`, above `n`, short | boundary | host JVM + iOS ✅ M1 |
| `groupKey``pk == xonly(sk)` | property | iOS ✅ M1 |
| `isValidScalar` vs `SecretKey.fromBytes` on the same seeds | cross-check | iOS ✅ M1 |
| `groupKey``nip44` self-ECDH round trip, and a different plane cannot read it | smoke | iOS ✅ M1 |
| hex round trip, lowercase, 64 chars | unit | host JVM + iOS ✅ M1 |
| wrap → unwrap round trip is **byte-identical** | integration, no network | iOS ✅ M2 |
| wrap carries exactly one ephemeral `p` tag; `created_at` shared by all three layers | property | iOS ✅ M2 |
| wrong channel id / wrong epoch / wrong plane cannot decrypt | negative | iOS ✅ M2 |
| impersonation, refused on publish **and** dropped on read | negative | iOS ✅ M2 |
| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
| `20013` vs `20014` discipline, both directions | negative | iOS ✅ M2 |
| Control readable from the read key + writers' pubkey alone; refuses to wrap; drops a non-staff wrap | integration | iOS ✅ M2 |
| 32 bytes → base64url = 43 chars, unpadded | unit | iOS ✅ M3 |
| `expires_at` ms/s conversion | unit | iOS ✅ M3 |
| a delivered Channel message raises the badge; our own message and a non-message raise nothing | property | iOS ✅ M4 |
| `markChannelRead` clears and republishes; a re-index carries the badge across | property | iOS ✅ M4 |
| a sent rumor is exactly `channel`/`epoch`/`ms` at `kind 9`, refused by the Guestbook and another Channel | unit | iOS ✅ M4 |
| `:shared:compileKotlinIosSimulatorArm64` and `:composeApp:compileDebugKotlinAndroid` after the M5 wiring, with the new classes confirmed present in `build/` | compile | Android + iOS ✅ M5 |
| The same three tasks after the M6 fold + UI, with the recompiled classes confirmed by timestamp | compile | Android + iOS ✅ M6 |
| A reaction or edit sent from Coop is understood by another Concord client | **not run** | needs a second client |
| A reaction or edit from another client folds into Coop's timeline | **not run** | needs a second client |
| Icons render as intended | **not run** | needs a device |
| Every M5 screen renders, navigates and snackbars | **not run** | needs `adb` |
| Logout actually erases the membership blob | **not run** | needs a device (risk 17) |
**RFC 5869 Test Case 1** (for reference):
```
IKM = 0x0b × 22
salt = 0x000102030405060708090a0b0c
info = 0xf0f1f2f3f4f5f6f7f8f9
L = 42
PRK = 0x077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5
OKM = 0x3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
```
**RFC 5869 Test Case 3** (zero-length salt — closest to Concord's usage):
```
IKM = 0x0b × 22
salt = (empty)
info = (empty)
L = 42
PRK = 0x19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04
OKM = 0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8
```
**Test Case 2** (80-octet inputs, L = 82) is likewise a known-answer vector here. It is worth
using even though Concord never asks for more than 32 bytes: it is the only vector that drives
Expand across three blocks, and it is what caught the empty-salt handling.
Additionally, run one **interop smoke test** against a real community before declaring the feature done. Nothing catches a label typo faster.
---
## Appendix A — Concord constants reference
### A.1 Durable plane kinds (inner rumor kinds)
| Kind | Function | Plane |
|---|---|---|
| `9` | Message (NIP-C7 shape) | Chat |
| `1111` | Threaded reply (NIP-22 shape) | Chat |
| `7` | Reaction (NIP-25 shape) | Chat |
| `5` | Delete (NIP-09 shape) | Chat |
| `1740` | Timer notice (CORD-08 §4) | Chat |
| `3302` | Edit | Chat |
| `3303` | Rekey blobs (CORD-06) | rekey addresses |
| `3306` | Join / Leave | Guestbook |
| `3308` | Control edition, sub-kinded below | Control |
| `3309` | Kick | Guestbook |
| `3310` | WebXDC peer signal | Chat |
| `3312` | Guestbook snapshot, chunked, refounder-signed | Guestbook |
### A.2 Ephemeral kinds (outer wrap `21059`)
| Kind | Function | Plane |
|---|---|---|
| `23311` | Typing indicator | Chat |
| `23313` | Voice presence (CORD-07) | Chat |
### A.3 Kinds outside the wrap
| Kind | Function |
|---|---|
| `33301` | Public invite bundle — addressable, signed by its per-link keypair at an empty `d`, `vsk` `6` (live) / `9` (revocation tombstone) |
| `33302` | Community List — addressable, one event per fragment at `d` = fragment index, NIP-44 to self |
| `13303` | Invite List — replaceable, NIP-44 to self |
### A.4 Control edition sub-kinds (`vsk`)
| vsk | Entity |
|---|---|
| `0` | Community metadata |
| `1` | Role |
| `2` | Channel metadata |
| `3` | Grant |
| `4` | Banlist |
| `5` | *reserved* (role ordering) |
| `6`, `9` | *claimed* by the addressable invite marker |
| `7` | *retired* (v1 owner attestation) |
| `8` | Invite-link registry |
| `10` | Dissolved tombstone — chainless, exempt from version discipline |
| `11` | Pin List |
### A.5 Tags
| Tag | Where | Semantics |
|---|---|---|
| `["ms", "<0..999>"]` | every rumor | true time = `created_at * 1000 + ms`; outside 0..999 is malformed → drop |
| `["p", "<ephemeral pubkey>"]` | outer stream wrap | NIP-59 reversed: fixed author, ephemeral `p` |
| `["channel", "<channel_id>"]` | every Chat rumor | MUST be committed inside the author-signed rumor; receiver MUST strict-equal check |
| `["epoch", "<n>"]` | every Chat rumor | same strict-equal check |
| `["q", "<rumor id>", "", "<author>"]` | `kind 9` inline quote | NIP-C7 |
| `K`/`E`/`P`, `k`/`e`/`p` | `kind 1111` reply | uppercase = thread root, lowercase = immediate parent (all rumor ids) |
| `["vsk", "<n>"]` | `kind 3308` | entity type |
| `["eid", "<hex32>"]` | `kind 3308` | stable coordinate |
| `["ev", "<n>"]` | `kind 3308` | this edition's version, climbs from 1 |
| `["ep", "<hash hex>"]` | `kind 3308` | previous edition hash; absent on the first |
| `["vac", "<grant eid>", "<version>", "<hash>"]` | `kind 3308`, `3309` | authority citation; absent when the owner acts |
| `["expiration", "<secs>"]` | Chat wrap + rumor | NIP-40; MUST be on both, same value; never on `kind 5` or `1740` |
| `["d", ""]` | `kind 33301` | empty identifier — the per-link pubkey makes the coordinate unique |
| `["d", "<index>"]` | `kind 33302` | fragment index in decimal |
| `["snap", "<id>", "<i>", "<n>"]` | `kind 3312` | snapshot chunk; one id + one `created_at` across all n |
| `["invite", "<creator hex>", "<label>"]` | `kind 3306` join | optional invite attribution |
| `["k", "3313"]` | outer Direct Invite wrap | the one deliberate outer-tag exception besides `expiration` |
### A.6 Permission bits (CORD-04 §3)
| Bit | Permission |
|---|---|
| `1<<0` | `MANAGE_ROLES` |
| `1<<1` | `MANAGE_CHANNELS` |
| `1<<2` | `MANAGE_METADATA` |
| `1<<3` | `KICK` |
| `1<<4` | `BAN` |
| `1<<5` | `MANAGE_MESSAGES` |
| `1<<6` | `CREATE_INVITE` |
| `1<<7` | retired (was `MANAGE_INVITES`) |
| `1<<8` | `VIEW_AUDIT_LOG` |
| `1<<9` | `MENTION_EVERYONE` |
| `1<<10`, `1<<12` | reserved |
| `1<<11` | `PIN_MESSAGES` |
**Staff** = any of `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `BAN`, `CREATE_INVITE`, `PIN_MESSAGES`, plus always the owner. Staff hold `control_root`.
`position` orders authority, **lower is higher**; owner is position 0 and never a Role; a member's rank is the lowest position among their Roles; an actor must **strictly** outrank its target.
### A.7 KDF label registry (CORD-02 A.6)
| Label | secret (ikm) | id | epoch | Yields |
|---|---|---|---|---|
| `concord/channel` | channel key *or* `community_root` | `channel_id` | yes | a Channel's group key |
| `concord/control` | `community_root` | `community_id` | yes | Control Plane **read** key |
| `concord/control-signer` | `control_root` | `community_id` | yes | Control Plane signer (staff only) |
| `concord/rekey-pseudonym` | prior `community_root` | `channel_id` | new_epoch | a channel rekey address |
| `concord/base-rekey-pseudonym` | prior `community_root` | `community_id` | new_epoch | a base rekey address |
| `concord/recipient-pseudonym` | `rotator_xonly ‖ recipient_xonly` | `scope_id` | new_epoch | a rekey blob locator |
| `concord/guestbook` | `community_root` | `community_id` | yes | Guestbook Plane group key |
| `concord/voice-signer` | channel key *or* `community_root` | `channel_id` | yes | SFU room keypair |
| `concord/voice-media` | channel key *or* `community_root` | `channel_id` | yes | 32-byte call media key |
| `concord/voice-sender` | `voice_media_key` | `sha256(identity)` | — | per-sender frame key |
| `concord/dissolved` | `community_id` | `0…0` | — | dissolution tombstone address |
| `concord/grant` | `community_id` | `member_xonly` | — | a member's Grant coordinate |
| `concord/banlist` | `community_id` | `0…0` | — | Banlist coordinate |
| `concord/pins` | `community_id` | `channel_id` | — | a Channel's Pin List coordinate |
| `concord/invite-links` | `community_id` | `creator_xonly` | — | a creator's Registry coordinate |
| `concord/invite-key` | `token` | `0…0` | — | public-invite decrypt key |
| `concord/invite-locator`, `concord/invite-signer` | — | — | — | *retired* |
### A.8 Other frozen derivations
```
community_id = sha256( utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32] )
prevcommit = sha256( utf8("concord/epoch-key-commitment") ‖ prev_epoch_be[8] ‖ prev_key[32] )
dissolved_pk = group_key("concord/dissolved", community_id, 0…0).pk
edition_hash = sha256(
len64(label) ‖ label // label = utf8 "vector-community/v1/edition"
‖ entity_id[32]
‖ version_be[8]
‖ (prev ? 0x01 ‖ prev[32] : 0x00 ‖ zero[32])
‖ len64(content) ‖ content ) // content bytes verbatim, never re-serialized
```
> `edition_hash`'s label string is literally `vector-community/v1/edition` in the spec — the reference implementation's `vector` prefix, *not* the `concord/` prefix used by every KDF label. Reproduce it exactly.
---
## Appendix B — Coop architecture reference
Points that constrain the implementation. Verified against the current tree (`feat/group-chat`, clean).
### B.1 Module layout
| Module | Targets | Notes |
|---|---|---|
| `:shared` | `androidTarget`, `iosArm64`, `iosSimulatorArm64` | All domain/data code in `commonMain` |
| `:composeApp` | `androidTarget` **only** | `androidMain` only — **no `commonMain` UI, no working iOS app** |
`shared` has Compose **runtime** but not UI/foundation/material3, so it can use `mutableStateOf` but cannot declare composables. **All new screens go in `composeApp/src/androidMain`.**
### B.2 Dependency wiring
No DI framework. The graph is hand-built as `by lazy` fields on `MainActivity`, passed positionally into `App(...)`, then:
- global values via `staticCompositionLocalOf` (`LocalNavigator`, `LocalProfileCache`, `LocalSettings`, `LocalConnectivity`, `LocalSnackbarHostState`, `LocalScanResult`)
- view models via an ad-hoc `ViewModelProvider.Factory` with a `when` on `modelClass`
- Activity-scoped VMs created above `NavDisplay`; parameterized VMs created inside `entry<…>` with a `key`
Repositories receive the Activity's `MainScope()` and hop to `Dispatchers.Default` per call via a `defaultDispatcher` constructor param. They never create their own scope.
### B.3 Navigation
JetBrains Navigation 3. Routes are a `@Serializable sealed interface Screen : NavKey`, a `rememberNavBackStack(Screen.Home)`, a `NavDisplay` with `entryProvider { entry<Screen.X> { … } }`, and a tiny `Navigator` wrapper exposing `navigate(route)` / `goBack()`.
Adding a screen = declare the route, create the file, register the entry, wire the VM (if any), wire the repository (if any), add a nav entry point and an icon.
### B.4 View models
`androidx.lifecycle.ViewModel` subclasses in `shared/commonMain`. Two shapes:
- **Façade** (`ChatViewModel`, 21 lines): re-exposes repository `StateFlow`s 1:1, forwards calls, `ErrorHost by repository`.
- **Stateful screen VM** (`ChatScreenViewModel`): `mutableStateListOf` / `mutableStateOf` + `viewModelScope`, still `ErrorHost by repository`.
UI collects with `collectAsStateWithLifecycle()`.
### B.5 Errors
`ErrorHost` (`errorEvents: SharedFlow<String>` + `showError(message)`) with `createErrorHost()`.
Chain: repository `: ErrorHost by createErrorHost()` → view model `: ErrorHost by repository` → one collector per VM in `App.kt:162-178` pushing into `snackbarHostState`.
A new feature's errors are only visible if a collector is added there.
### B.6 Persistence
| Mechanism | Use |
|---|---|
| `AppStorage.get/set` | Plaintext DataStore preferences |
| `AppStorage.getSecret/setSecret` | AES-GCM via Android Keystore (`<key>_encrypted` + `<key>_iv`) |
| LMDB (`client.database()`) | Nostr events, at `filesDir/nostr`; wiped by `nostr.prune()` on logout |
Current keys: `app_settings` (plain), `user_signer` / `app_keys` (secret), `notification_banner_dismissed` (plain).
The **only** JSON persistence pattern is `SettingsRepository`: one key, `Json { ignoreUnknownKeys = true; encodeDefaults = true }`, a `MutableStateFlow`, and `update(transform)`.
### B.7 Established idioms to mirror
| Concern | Mirror |
|---|---|
| Synthetic LMDB index rows | `MessageManager.setCachedRumor` / `getCachedRumor` |
| Relay connect + subscribe | `MessageManager.getUserMessages` |
| Secret-backed single-blob repository | `SettingsRepository` |
| Repository shape | `ChatRepository` (`ErrorHost`, `MutableStateFlow`, `stateIn(scope, WhileSubscribed(5000), …)`, `catch (e: CancellationException) { throw e }`) |
| List row / empty state / dialogs | `HomeScreen`, `ContactListScreen`, `SettingsScreen` |
### B.8 Known constraints
- `Nostr.handleNotifications` must remain the **only** `client.notifications()` consumer.
- `shared` cannot declare composables.
- No `@Preview` composables exist anywhere — don't introduce one just for this feature.
- All user-facing text is hardcoded English literals; there is no `Res.string` convention. Matching existing convention means inline literals.
- `client.database().saveEvent` replaces addressable events by `d` tag.
- `concord`'s repository must be constructed in `MainActivity`, not in `shared`.