54 KiB
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
Requirements this plan honors
- No over-engineering, no over-complication.
- Reuse existing APIs and types from the nostr SDK; only create new code where the SDK has nothing.
- Follow the existing Coop architecture (no new patterns, no new DI framework, no new persistence layer).
Table of contents
- Verdict
- Capability check — exists vs. must be written
- Scope — v1 vs. deferred
- Architecture
- Files
- Crypto layer — exact algorithm
- Wire format — exact stack
- Subscriptions
- Persistence
- Invites
- UI
- Milestones
- Risks and open decisions
- Verification plan
- Appendix A — Concord constants reference
- Appendix B — Coop architecture reference
1. Verdict
Feasible without touching the Rust SDK. Every cryptographic primitive Concord needs is reachable through nostr-sdk-kmp except HKDF-SHA256, which Okio already gives us the pieces for (ByteString.hmacSha256) — and Okio is already a commonMain dependency.
The hard part is not crypto. It's that Concord's wrap is NIP-59 inverted (fixed author, ephemeral p), so it cannot ride the existing MessageManager giftwrap pipeline. It needs a parallel plane.
2. Capability check — exists vs. must be written
Verified against the published artifact su.reya:nostr-sdk-kmp:0.3.2 (sources jar), not just documentation.
2.1 Exists — use it, don't wrap it
| Concord need | Existing API | Notes |
|---|---|---|
| NIP-44 with an arbitrary keypair | nip44Encrypt(sk, pk, content, Nip44Version.V2)nip44Decrypt(sk, pk, payload) |
Concord's conv_key = nip44_conversation_key(sk, pk) is NIP-44's conversation key. Pass the plane's own keypair → self-ECDH happens for free. Do not hand-roll this. |
| x-only pubkey from a secret | Keys(secretKey).publicKey() |
secp256k1 x-only, matches Concord's byte-exact rule |
| Arbitrary kind numbers | Kind(1059u), Kind(20013u), Kind(20014u), Kind(3308u), Kind(9u)… |
Kind(kind: UShort) is a public constructor |
| Sign a wrap with a derived key | EventBuilder(kind, content).tags(…).finalize(Keys(sk)) |
Keys implements NostrSigner |
| Sign a seal with the user's key (incl. NIP-46 bunker) | .finalizeAsync(nostr.signer) |
UniversalSigner already proxies this |
| Decode events | Event.fromJson, UnsignedEvent.fromJson(…).ensureId(), Event.verify() |
Already used in Messaging.extractRumor |
| Multi-letter tags | Tag.custom("channel", listOf("…")), Tag.parse(List<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 |
| 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_rootholder could publish forged metadata or channels and v1 would display it. This is bounded (only staff holdcontrol_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
handleNotificationsrecording that it must remain the onlyclient.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 and M2 were verified during development and the checks were then discarded, so the tree carries no commonTest or iosTest sources for Concord.
The consequence to plan around is in §14 and risk 13.
5.5 Modified (6 files, all small)
| File | Change |
|---|---|
shared/.../nostr/Nostr.kt |
add val concord, 2 routing branches |
composeApp/.../MainActivity.kt |
private val concordRepository by lazy { … }, add to App(...) params |
composeApp/.../App.kt |
factory branch, viewModel(...), 3 × entry<…>, snackbar collector |
composeApp/.../Navigation.kt |
Screen.Communities, Screen.Community(id), Screen.Channel(communityId, channelId), Screen.JoinCommunity |
composeApp/.../screens/HomeScreen.kt |
one entry in BottomMenuList |
composeApp/src/androidMain/composeResources/drawable/ic_communities.xml |
icon |
6. Crypto layer — exact algorithm
This is the part that must be byte-exact or nothing interoperates. Freeze it in one file, with the governing spec section quoted above each function.
6.1 HKDF info layout (CORD-02 A.1)
info = utf8(label) ‖ 0x00 ‖ id[32] ‖ epoch_be[8] // epoch omitted for labels marked "—"
salt = ∅ (zero-length, NOT 32 zero bytes)
len = 32
id is always present, 32 bytes, all-zeroes where a label has no meaningful id. The epoch is the only omittable field.
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:
- The general
lengthloop (rather than hardcoding 32) unlocks RFC 5869's L=42 and L=82 vectors. - The optional
saltunlocks 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
seedis not a valid scalar, append one incrementing counter byte to the hkdfinfoand 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 needed only if community creation is added (M4.5).
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:
- rumor —
kindgiven, stamped with the binding tags andms,finalizeUnsigned(author).ensureId(). Never signed. - seal —
kind= the plane'sSealForm.20013content is the NIP-44 ciphertext of the rumor JSON;20014content is that JSON byte-verbatim.customCreatedAt(rumor.createdAt()), then.finalizeAsync(signer)so the real author signs it and a NIP-46 bunker works. - wrap —
kind 1059, content = NIP-44 of the seal JSON, one ephemeralptag,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_atis never tweaked, and the seal and wrap reuse the rumor's value verbatim so all three layers agree. Sub-second ordering lives in themstag; 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:
kindis1059— otherwise not ours- 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
event.verify()— id and signature- NIP-44 decrypt the wrap content with the read key → seal
seal.verify(), andseal.kind()matches the plane'sSealForm(the other form is a discipline violation, not a variant)20013→ NIP-44 decrypt the seal content;20014→ take it verbatim, never re-parsed as an eventUnsignedEvent.fromJson(…).ensureId(), andrumor.author() == seal.author()— NIP-59's impersonation check- 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-59p-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 uniqued, LMDB replaces every message with the previous one sharing the coordinate, silently eating the history.MessageManager.setCachedRumoruses the same trick for the same reason.
9.3 Control fold (v1, non-gating)
- Query all stored editions for the community (
r = communityIdHex). - Parse to
ControlEdition { vsk, eid, ev, ep, content, actor }. - Group by
(vsk, eid); per group take the highestev(refuse to downgrade) whoseepchain is intact. Same-version ties break on the lower rumor id — nevercreated_at. - Project:
vsk 0→CommunityMeta(name, description, relays, icon, banner,message_expiration,custom)vsk 2→ChannelMeta(name,private,deleted,custom)
- Ignore every other
vskin v1.
Not implemented in v1: authority resolution against the Roster (vsk 1 / vsk 3), vac citation validation, and vsk 4 banlist filtering. See Risks.
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
ktag is unsigned relay-visible bytes — a hint, never authority. An invite is whatever unwraps to a kind3313rumor, 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
{
"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:
community_id == sha256("concord/community" ‖ owner ‖ owner_salt)— refuse the bundle otherwise.- Reject more than a sane channel count (spec's reference ceiling is 256).
- Truncate
relaysto the Community's cap (5 recommended). - If
expires_atis in the past: preview still renders, joining refuses.
10.4 Join flow
- Parse link → decode fragment → require
version == 4. - Derive
bundle_key. - Fetch coordinate
(33301, link_signer, "")from the bootstrap relays. - Check liveness — a
["vsk","9"]tombstone at the coordinate replaces the bundle. - Decrypt content →
CommunityInvite. - Validate per §10.3.
- Preview only. No join, no subscribe, no icon fetch, no presence.
- On explicit accept: persist
Membership→ connect relays → subscribe → publish Guestbookjoin.
// kind 3306
{ "kind": 3306, "pubkey": "<member>", "content": "join",
"tags": [ ["ms", "128"], ["invite", "<creator hex>", "<label>"] ] } // invite tag optional
expires_atunit 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
// 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-scopedCommunityScreenViewModel)entry<Screen.Channel> { key -> … }(entry-scopedChannelScreenViewModel, keyed likeScreen.Chat)- a fourth
launch { concordViewModel.errorEvents.collect { snackbarHostState.showSnackbar(it) } }in the existingLaunchedEffectatApp.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 ✅
hkdfInfomatches a golden hex annotated with the A.1 layout ✅communityId/prevCommit/editionHashmatch digests computed outside the codebase ✅groupSeedis deterministic, separates label/id/epoch/secret, and retries with the A.3 counter ✅isValidScalaragrees with the secp256k1 range at 0, 1,n-1,n, aboven✅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 9message for a channel → wrap → unwrap → byte-identical rumor JSON, withchannel/epoch/msintact andcreated_atshared by all three layers ✅ - the wrap carries exactly one ephemeral
ptag ✅ - a different channel id, a different epoch, or the Guestbook plane cannot decrypt it ✅
- a
channelorepochmismatch, 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 ✅
20014is rejected on a Chat plane, and20013is 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
ConcordStore.kt, ConcordInvite.kt, ConcordControl.kt, ConcordManager read path, Nostr.kt routing.
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.
M4 — Write
sendChannelMessage, Guestbook join (3306, content: "join"), unreadCount.
Done when: a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
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. |
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. |
13.3 Open decision
Community creation in v1? See M4.5. 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 and M2,
then discarded; the tree carries no commonTest or iosTest sources for Concord. They are
recorded here because they are what establishes the wire format is right, and because whoever
next touches the crypto 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:iosSimulatorArm64Testis the only target here that can load the SDK (risk 13), so a throwaway check inshared/src/iosTest— written, run, deleted — is the cheapest way to exercise anything touchingSecretKey,Keys,nip44*orEventBuilderwhile 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 | ⏳ M3 |
expires_at ms/s conversion |
unit | ⏳ M3 |
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 literallyvector-community/v1/editionin the spec — the reference implementation'svectorprefix, not theconcord/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.Factorywith awhenonmodelClass - Activity-scoped VMs created above
NavDisplay; parameterized VMs created insideentry<…>with akey
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 repositoryStateFlows 1:1, forwards calls,ErrorHost by repository. - Stateful screen VM (
ChatScreenViewModel):mutableStateListOf/mutableStateOf+viewModelScope, stillErrorHost 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.handleNotificationsmust remain the onlyclient.notifications()consumer.sharedcannot declare composables.- No
@Previewcomposables exist anywhere — don't introduce one just for this feature. - All user-facing text is hardcoded English literals; there is no
Res.stringconvention. Matching existing convention means inline literals. client.database().saveEventreplaces addressable events bydtag.concord's repository must be constructed inMainActivity, not inshared.