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

78 KiB
Raw Blame History

Concord Protocol Implementation Plan

Implementation plan for adding Concord communities and channels to Coop.

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, §14)
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.

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
  2. Capability check — exists vs. must be written
  3. Scope — v1 vs. deferred
  4. Architecture
  5. Files
  6. Crypto layer — exact algorithm
  7. Wire format — exact stack
  8. Subscriptions
  9. Persistence
  10. Invites
  11. UI
  12. Milestones
  13. Risks and open decisions
  14. Verification plan
  15. Appendix A — Concord constants reference
  16. Appendix B — Coop architecture reference

1. Verdict

Feasible without touching the Rust SDK. Every cryptographic primitive Concord needs is reachable through nostr-sdk-kmp except HKDF-SHA256, which Okio already gives us the pieces for (ByteString.hmacSha256) — and Okio is already a commonMain dependency.

The hard part is not crypto. It's that Concord's wrap is NIP-59 inverted (fixed author, ephemeral p), so it cannot ride the existing MessageManager giftwrap pipeline. It needs a parallel plane.


2. Capability check — exists vs. must be written

Verified against the published artifact su.reya:nostr-sdk-kmp:0.3.2 (sources jar), not just documentation.

2.1 Exists — use it, don't wrap it

Concord need Existing API Notes
NIP-44 with an arbitrary keypair nip44Encrypt(sk, pk, content, Nip44Version.V2)
nip44Decrypt(sk, pk, payload)
Concord's conv_key = nip44_conversation_key(sk, pk) is NIP-44's conversation key. Pass the plane's own keypair → self-ECDH happens for free. Do not hand-roll this.
x-only pubkey from a secret Keys(secretKey).publicKey() secp256k1 x-only, matches Concord's byte-exact rule
Arbitrary kind numbers Kind(1059u), Kind(20013u), Kind(20014u), Kind(3308u), Kind(9u) Kind(kind: UShort) is a public constructor
Sign a wrap with a derived key EventBuilder(kind, content).tags(…).finalize(Keys(sk)) Keys implements NostrSigner
Sign a seal with the user's key (incl. NIP-46 bunker) .finalizeAsync(nostr.signer) UniversalSigner already proxies this
Decode events Event.fromJson, UnsignedEvent.fromJson(…).ensureId(), Event.verify() Already used in Messaging.extractRumor
Multi-letter tags Tag.custom("channel", listOf("…")), Tag.parse(List<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)
client.addRelay(url, RelayCapabilities.read())
SendEventTarget.to(relays)
Same idiom as MessageManager.getUserMessages
Local persistence client.database().saveEvent/query (LMDB) Mirror MessageManager.setCachedRumor
Secret storage AppStorage.setSecret (AES-GCM via Android Keystore) Room storage does not; roots must use this
NIP-40 expiry Tag.expiration(Timestamp) For deferred CORD-08
SHA-256 Okio ByteString.sha256() okio already in shared/commonMain
HMAC-SHA256 Okio ByteString.hmacSha256(key) In commonMain → works on iOS targets too
base64url unpadded kotlin.io.encoding.Base64.UrlSafe (Kotlin 2.4) Base64 already imported in BlossomClient
scalar validity test SecretKey.fromBytes(bytes) throws for invalid scalars This is scalar_normalize's reject branch
hex ↔ bytes Okio ByteString.decodeHex(), ByteString.hex()

2.2 Missing — must be written (small, pure Kotlin)

Need Size Where
HKDF-SHA256 (Extract + Expand) ~15 lines on top of Okio concord/ConcordCrypto.kt
hkdf info layout (label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]) ~10 lines same
scalar_normalize retry loop ~10 lines same
community_id, edition_hash, prevcommit ~15 lines same

Everything else is composition of existing SDK calls.


3. Scope — v1 vs. deferred

Concord is 8 CORDs. Building all at once contradicts requirement 1.

3.1 v1 — ships

CORD What ships
01 Private Streams Full wrap/seal/rumor stack: 1059 / 20013 / 20014 + ephemeral p
02 Communities community_id, community_root, control_root (held, not used), epochs, Control/Chat/Guestbook planes. Read-only Control fold for vsk 0 (metadata) + vsk 2 (channels) only. Guestbook: publish join on join, do not fold.
03 Channels Public + Private, key derivation, channel/epoch binding checks, send/receive kind 9, with kind 7 reactions and kind 3302 edits folded onto it (M6)
05 Invites Redeem only: Direct Invite (3313) + public link bundle (33301) + fragment decoder. No minting.

3.2 Deferred — explicitly out of v1

CORD / feature Why deferred
Community creation Decided, not deferred: out of scope for mobile. Coop is a joiner. Owner-side minting (community_id, community_root, control_root, genesis vsk 0 + #general vsk 2, written as 20014 plaintext seals) is a desktop/reference-client job. Two things follow from this and are worth stating: concord/control-signer is never used here, and the app cannot demo itself — there must be a real community minted elsewhere, which is exactly what the interop smoke test needs.
04 Roles Roster fold + vac citation + outranking rules. Large. Needed for moderation, not for chat.
06 Rekeys Epoch rotation. v1 reads the epoch it was invited to and holds old keys read-only.
05 minting Invite List (13303), Registry (vsk 8), revocation tombstones.
07 A/V Needs a broker + SFU and WebRTC. Genuinely a separate project.
08 Disappearing Cheap to add later; Tag.expiration exists.
Pins / Threads / WebXDC Not needed for a first cut.
Deletes (kind 5) Registered, not folded: a message another client deleted still renders here. Same shape as M6's edits, so ~the same cost when someone asks.
Replies (kind 1111) Reuse plane.key.rumor the same way M6 does; the work is the thread UI, not the wire format.
Community List (33302) 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

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.

// 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:

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:

// 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.

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.

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

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).

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.

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. rumorkind given, stamped with the binding tags and ms, finalizeUnsigned(author).ensureId(). Never signed.
  2. sealkind = 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. wrapkind 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.


8. Subscriptions

One subscription id per community, refreshed whenever the relay set or channel set changes:

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.

@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.

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:

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 0CommunityMeta (name, description, relays, icon, banner, message_expiration, custom)
    • vsk 2ChannelMeta (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.

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.

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

{
  "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.
// 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.

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 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

// 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). 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, groupKeypk == 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
  • groupKeypk == 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.ktparseInviteLink, decodeInviteFragment (CORD-05 §3), the stock relay dictionary, inviteBundleKey, decryptInviteBundle, problems() validation, relaySet, toMembership
  • ConcordControl.kteditionOf + 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.

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_keyscalar_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 reachlogout 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. 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
groupKeypk == xonly(sk) property iOS M1
isValidScalar vs SecretKey.fromBytes on the same seeds cross-check iOS M1
groupKeynip44 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 StateFlows 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.