1130 lines
63 KiB
Markdown
1130 lines
63 KiB
Markdown
# 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. [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` |
|
||
| **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. 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/`
|
||
|
||
| 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<UnsignedEvent>`) |
|
||
|
||
### 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 Tests — **none are kept**
|
||
|
||
Decision: no test files are maintained for this feature. M1–M4 were verified during development and the checks were then discarded, so the tree carries no Concord sources under `commonTest` or `iosTest`.
|
||
|
||
The consequence to plan around is in §14 and risk 13.
|
||
|
||
### 5.5 Modified (7 files, all small)
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches |
|
||
| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
|
||
| `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. 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 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
|
||
|
||
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.
|
||
|
||
| Screen | Mirrors | Notes |
|
||
|---|---|---|
|
||
| `CommunitiesScreen` | `HomeScreen` list + FAB | Community avatar (`Avatar`), name, channel count. Empty state per `ContactListScreen` convention. |
|
||
| `CommunityScreen` | `HomeScreen` | Channel rows split public / private, lock icon on private. `#general` comes from genesis. |
|
||
| `ChannelScreen` | `ChatScreen` | Reuse `DateSeparator`, `ChatInput`, `Avatar`. Discord-style (author shown per message) rather than Coop's DM style. |
|
||
| `JoinCommunityScreen` | `NewChatScreen` | Paste link or QR scan (reuse `LocalScanResult` / `Screen.Scan`), preview card, Join button. |
|
||
|
||
### 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 settings/info row.
|
||
- Warn on community creation (if implemented) that losing the owner key kills the community — there is no succession by design.
|
||
|
||
---
|
||
|
||
## 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 1–3 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
|
||
|
||
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 (optional)
|
||
|
||
Owner mints `community_id`, `community_root`, `control_root`, genesis metadata (`vsk 0`) + `#general` (`vsk 2`); writes to the Control plane via the `20014` plaintext-seal path signed by `concord/control-signer`.
|
||
|
||
**Why consider it:** ~150 lines on top of M3, makes the feature demoable without depending on an existing Concord client, and exercises the Control-plane *write* path that join-only never touches.
|
||
|
||
**Why it might wait:** requires `control_root` handling and careful `20014` discipline.
|
||
|
||
### M5 — UI polish
|
||
|
||
`ConcordRepository` / view models / 4 screens / `BottomMenuList` entry / icon / error snackbars.
|
||
|
||
**Done when:** the whole flow works without adb logcat.
|
||
|
||
### M6 — Cheap wins (optional)
|
||
|
||
Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI. Threads (`kind 1111`) are more work — separate milestone.
|
||
|
||
---
|
||
|
||
## 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. | Surface a backup warning on community creation. |
|
||
| 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). |
|
||
|
||
### 13.3 Open decision
|
||
|
||
**Community creation in v1?** See [M4.5](#m45--community-creation-optional). Recommendation: yes, after M4 — it makes the feature demoable standalone.
|
||
|
||
---
|
||
|
||
## 14. Verification plan
|
||
|
||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M4,
|
||
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.
|
||
|
||
### 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 |
|
||
|
||
**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 M3 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`.
|