fix(concord): address private channels by their own key, not the community root

Every path derived every channel's Chat Plane from the `community_root`,
`private: true` ones included — at six independent call sites (plane
registry, subscription planner, session re-fold, and three send paths).
CORD-03 §1 keys a private channel by the per-member `channel_key` delivered
in the kind-13302 bundle, at that channel's own epoch.

The consequence was not a weakly-enforced ACL but no ACL at all: a private
channel sat on an address every member of the community could derive, so it
was readable *and postable* by people never granted its key. Symmetrically,
a private channel created by a conformant client was addressed somewhere we
never looked, which is the "renders an empty room" symptom. `privateChannel`
had exactly one caller in the tree — a Quartz test — and `private` was
consulted on no send or subscribe path.

Introduce `ConcordChannelPlanner`: one resolver mapping (entry, folded
state) to per-channel plane coordinates — a `write` plane and the `reads`
that span held epochs. Public channels resolve off the root as before;
private channels off the bundle key. Every consumer now reads it, so the
choice of secret is made once rather than re-decided per call site.

The epoch travels with the plane. A private channel's wraps bind to its
channel epoch, so passing the root epoch would have rejected every one of
them on arrival even with the right key — the session's current-plane map
carries the epoch for the same reason.

A private channel we hold no key for is omitted from the channel list and
the Messages inbox, matching the reference client's `channelsView`, and no
write coordinate exists for it, so no send path can fall back to the root.
Reaching one anyway (a stale route) now yields `PostingGate.NoKey` instead
of a composer that would publish nowhere valid.

Migration: "private" channels created by earlier Amethyst builds keep their
history on the root-derived plane, which is no longer read for a private
channel. That history is orphaned deliberately — reading it would mean
continuing to accept traffic on a plane the whole community can write to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
This commit is contained in:
Claude
2026-07-30 02:55:57 +00:00
parent 4bb44de5e2
commit 26795fb9a8
13 changed files with 607 additions and 141 deletions
@@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlane
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanner
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
@@ -654,12 +656,16 @@ class Account(
val state = session.state.value ?: continue
val communityId = session.entry.id
val relays = relaysByCommunity[communityId] ?: emptySet()
// Every folded channel, openable or not: a channel we hold no key for is hidden from the
// display surfaces, but its object still learns the truth so arriving at one anyway (a
// stale route) explains itself instead of offering a composer that can't publish.
val openable = session.openableChannelIds().toSet()
for (channelIdHex in state.channels.keys) {
val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
// Invalidate the channel's metadata flow only on a real change so the Messages-row
// name + community chip recompose when the fold first resolves them (they observe
// metadata.stateFlow via observeChannel), without churning every row every tick.
if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo()
if (channel.updateFrom(state, relays, myPubKey, channelIdHex in openable)) channel.updateChannelInfo()
channel.notes
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
.forEach { channel.removeNote(it) }
@@ -2354,6 +2360,26 @@ class Account(
return ConcordInviteResult.Joined(bundle.communityId)
}
/**
* The community entry plus the plane a message we author on [channelIdHex] must ride, or null when
* we may not write there at all.
*
* Which secret addresses the channel is [ConcordChannelPlanner]'s call: the shared community root
* for a public channel, the per-member key delivered in our own kind-13302 bundle for a private
* one. Null means the community isn't joined/folded, or it is a private channel we were never
* granted and in that case publishing anyway is not a graceful degradation. Deriving from the
* root would put private traffic on the plane every member of the community can read and write,
* which is precisely the bug this replaced.
*/
private fun concordWritePlane(
communityId: String,
channelIdHex: String,
): Pair<ConcordCommunityListEntry, ConcordChannelPlane>? {
val session = concordSessions.sessionFor(communityId) ?: return null
val plane = ConcordChannelPlanner.writePlane(session.entry, session.state.value, channelIdHex) ?: return null
return session.entry to plane
}
/**
* Post [text] to a Concord channel: derive the channel plane key, build an
* encrypted-seal kind-1059 wrap authored by that plane key (not our identity),
@@ -2371,9 +2397,8 @@ class Account(
imetas: List<IMetaTag> = emptyList(),
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false
val channelKey = plane.key
// NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the
// custom image everywhere (the kind-9 rumor carries them; recipients render via the tags).
@@ -2386,13 +2411,13 @@ class Account(
// the user attached media); an inline reply is a kind-9 message quoting the parent; a
// fresh post is a plain kind-9 message.
parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() ->
ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags)
parent != null && replyMode == ReplyMode.MINICHAT ->
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags)
parent != null ->
ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags)
else ->
ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags)
}
trackConcordDelivery(entry, channelKey, wrap)
publishConcordWrap(entry, wrap)
@@ -2413,12 +2438,11 @@ class Account(
): Boolean {
if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text)
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false
val channelKey = plane.key
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message.
val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags)
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags)
trackConcordDelivery(entry, channelKey, wrap)
publishConcordWrap(entry, wrap)
return true
@@ -2555,13 +2579,12 @@ class Account(
val target = note.event ?: return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false
val channelKey = plane.key
// A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to
// resolve to an image on the other side; a plain unicode/`+` reaction yields no tags.
val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags)
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
@@ -2587,12 +2610,11 @@ class Account(
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false
val channelKey = plane.key
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message.
val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags)
val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
@@ -2607,9 +2629,8 @@ class Account(
channelIdHex: String,
) {
if (!isWriteable()) return
val entry = concordSessions.sessionFor(communityId)?.entry ?: return
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return
val wrap = ConcordActions.buildChannelTyping(signer, plane.key, channelIdHex, plane.epoch, TimeUtils.now())
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) client.publish(wrap, relays)
}
@@ -307,12 +307,14 @@ fun ConcordChannelListScreen(
}
},
) { padding ->
// Only channels this account can open. A private channel whose per-member key was never
// delivered to us has no derivable plane (CORD-03 §1), so its row would open a room that can
// never load — the reference client omits it for the same reason.
val channels =
state
?.channels
?.entries
?.toList()
.orEmpty()
remember(state, session, revision) {
val folded = state?.channels.orEmpty()
session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } }
}
if (channels.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
@@ -331,9 +333,9 @@ fun ConcordChannelListScreen(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
) {
items(channels, key = { it.key }) { entry ->
val def = entry.value.definition
val name = def.name.ifBlank { entry.key }
items(channels, key = { it.first }) { entry ->
val def = entry.second.definition
val name = def.name.ifBlank { entry.first }
val icon =
when {
def.voice == true -> MaterialSymbols.Mic
@@ -341,24 +343,24 @@ fun ConcordChannelListScreen(
else -> MaterialSymbols.Tag
}
val typingAuthors =
remember(typingMap, typingNow, entry.key) {
(typingMap[entry.key] ?: emptyMap())
remember(typingMap, typingNow, entry.first) {
(typingMap[entry.first] ?: emptyMap())
.filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS }
.keys
.sorted()
}
ConcordChannelListRow(
communityId = communityId,
channelKey = entry.key,
channelKey = entry.first,
channelName = name,
icon = icon,
isVoice = def.voice == true,
typingAuthors = typingAuthors,
canManageChannels = canManageChannels,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onClick = { nav.nav(Route.Concord(communityId, entry.first)) },
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
@@ -191,8 +191,9 @@ class ChatroomListKnownFeedFilter(
when (account.settings.concordViewMode.value) {
ConcordViewMode.INLINE ->
account.concordSessions.sessions().flatMap { session ->
val state = session.state.value ?: return@flatMap emptyList<Note>()
state.channels.keys.map { channelIdHex ->
// Openable channels only: a private channel whose key was never delivered to
// us has no derivable plane, so a row for it could never fill or be posted to.
session.openableChannelIds().map { channelIdHex ->
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex))
channel.newestConcordNote(account) ?: channel.placeholderNote()
}
@@ -201,9 +202,10 @@ class ChatroomListKnownFeedFilter(
ConcordViewMode.GROUPED ->
// One row per joined community, carrying the newest message across ALL its channels.
account.concordSessions.sessions().mapNotNull { session ->
val state = session.state.value ?: return@mapNotNull null
if (session.state.value == null) return@mapNotNull null
val newest =
state.channels.keys
session
.openableChannelIds()
.mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) }
.maxByOrNull { it.createdAt() ?: 0L }
ConcordServerRoomNote(session.entry.id, newest)
@@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
@@ -66,16 +65,6 @@ data class ConcordChatMessage(
val epoch: Long,
)
/**
* One channel's Chat Plane at a prior epoch: the epoch-invariant [channelIdHex], the [epoch] the
* wraps are bound to (for `isBoundTo` validation), and the derived [key] to decrypt them.
*/
data class HistoricalChannelPlane(
val channelIdHex: HexKey,
val epoch: Long,
val key: GroupKey,
)
/**
* Concord community verbs pure builders, plane-key derivation, relay-filter
* assembly, and event folding usable from amy CLI, the Android app, and any other
@@ -95,12 +84,29 @@ object ConcordActions {
rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch)
/**
* A **public** channel's Chat Plane: keyed by the shared `community_root`, so every member
* derives it with no key distribution. Never use this for a `private: true` channel see
* [privateChannel] and prefer [ConcordChannelPlanner], which picks between the two.
*/
fun publicChannel(
communityRoot: ByteArray,
channelId: ByteArray,
rootEpoch: Long,
): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch)
/**
* A **private** channel's Chat Plane: keyed by the per-member `channel_key` delivered in the
* kind-13302 entry ([ConcordCommunityListEntry.privateChannels]) at that channel's own epoch
* (CORD-03 §1). Only members granted the key can derive the address at all, which is the whole
* of the private-channel ACL there is no server to enforce one.
*/
fun privateChannel(
channelKey: ByteArray,
channelId: ByteArray,
channelEpoch: Long,
): GroupKey = ConcordChannelKeys.privateChannel(channelKey, channelId, channelEpoch)
/**
* How many prior epochs of channel history to backfill. A CORD-06 Refounding rotates the
* `community_root` and bumps the epoch, so pre-refounding messages live under a *different*
@@ -112,25 +118,6 @@ object ConcordActions {
*/
const val MAX_BACKFILL_EPOCHS = 8
/**
* The historical Chat Plane keys for [channelIdsHex] across the prior epochs in [heldRoots]
* (newest-held first, bounded to [MAX_BACKFILL_EPOCHS]). The channel id is epoch-invariant, so a
* message decrypted under a held root lands in the same channel as the current-epoch ones.
*/
fun historicalChannelPlanes(
heldRoots: List<HeldRoot>,
channelIdsHex: Collection<HexKey>,
): List<HistoricalChannelPlane> =
heldRoots
.sortedByDescending { it.epoch }
.take(MAX_BACKFILL_EPOCHS)
.flatMap { held ->
val rootBytes = held.key.hexToByteArray()
channelIdsHex.map { channelIdHex ->
HistoricalChannelPlane(channelIdHex, held.epoch, publicChannel(rootBytes, channelIdHex.hexToByteArray(), held.epoch))
}
}
/** The Guestbook Plane address for a community at [rootEpoch] — where join/leave motions ride. */
fun guestbookPlane(
communityRoot: ByteArray,
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
/**
* One address a channel's traffic rides at, together with the [epoch] its wraps are bound to.
*
* The epoch travels *with* the address rather than being taken from the community: a public
* channel's plane is keyed by the `community_root` at the **root** epoch, while a private
* channel's is keyed by its own delivered key at its **own** channel epoch (CORD-03 §1). Binding
* a private channel's wraps against the root epoch would reject every one of them.
*/
data class ConcordChannelPlane(
val channelIdHex: HexKey,
val epoch: Long,
val key: GroupKey,
)
/**
* Every plane coordinate of one channel **this account can actually open**.
*
* [reads] is what to subscribe and decrypt, newest epoch first, and always contains [write].
* A public channel reads across every held root epoch, so history survives a CORD-06 Refounding;
* a private channel has exactly one, because a per-channel rekey replaces its delivered key
* rather than accumulating epochs.
*/
data class ConcordChannelPlanes(
val channelIdHex: HexKey,
val isPrivate: Boolean,
/** Where our own new messages are published, and the epoch to stamp them with. */
val write: ConcordChannelPlane,
/** Every address to watch, [write] first. */
val reads: List<ConcordChannelPlane>,
)
/**
* Resolves a community's channels into the plane coordinates this account can use the single
* place that decides which secret addresses a channel.
*
* It exists because that decision was previously made independently at six call sites (the plane
* registry, the subscription planner, the session's re-fold, and three send paths), and every one
* of them derived from the `community_root`. For a `private: true` channel that is the **wrong
* secret**: its plane is keyed by a per-member key delivered in the kind-13302 bundle
* ([ConcordCommunityListEntry.privateChannels]), so deriving from the root put private traffic on
* an address every member of the community could read and write and, in the other direction,
* made a correctly-addressed private channel look permanently empty.
*
* A channel whose key we do not hold is **omitted entirely**, matching the reference client: the
* room cannot be opened, and offering a row that opens nothing is worse than not offering it.
*/
object ConcordChannelPlanner {
/**
* Every channel of [state] this account can open, in fold order, followed by any private
* channel whose key we hold but whose definition has not folded yet (a fresh join sees its
* private rooms before the Control Plane catches up; the fold's name wins once it lands).
*
* A `null` [state] means the Control Plane has not folded, so only bundle-held private
* channels are known.
*/
fun channelPlanes(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState?,
): List<ConcordChannelPlanes> {
val privateKeys = privateKeysOf(entry)
val folded = state?.channels.orEmpty()
val out = ArrayList<ConcordChannelPlanes>(folded.size + privateKeys.size)
for ((channelIdHex, channel) in folded) {
val planes =
if (channel.definition.private) {
privateKeys[channelIdHex]?.let { privatePlanes(channelIdHex, it) }
} else {
publicPlanes(entry, channelIdHex)
}
planes?.let { out.add(it) }
}
for ((channelIdHex, held) in privateKeys) {
if (channelIdHex in folded) continue
out.add(privatePlanes(channelIdHex, held))
}
return out
}
/**
* The coordinates of one channel, or null when this account cannot open it an unheld private
* channel, or a channel id absent from both the fold and the bundle.
*
* Derives only the requested channel's keys, so a send path doesn't pay for the whole community.
*/
fun channelPlanesFor(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState?,
channelIdHex: HexKey,
): ConcordChannelPlanes? {
val definition = state?.channels?.get(channelIdHex)?.definition
val held = privateKeysOf(entry)[channelIdHex]
return when {
// Known private, either from the fold or from the bundle alone (fold still catching up).
definition?.private == true || (definition == null && held != null) ->
held?.let { privatePlanes(channelIdHex, it) }
definition != null -> publicPlanes(entry, channelIdHex)
else -> null
}
}
/** Where a new message on [channelIdHex] must be published, or null if we may not write there. */
fun writePlane(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState?,
channelIdHex: HexKey,
): ConcordChannelPlane? = channelPlanesFor(entry, state, channelIdHex)?.write
/**
* The bundle's delivered private-channel keys by channel id. Last wins: the bundle carries one
* key per private channel, since a per-channel rekey replaces the entry instead of appending an
* epoch which is also why a private channel has a single read plane.
*/
private fun privateKeysOf(entry: ConcordCommunityListEntry): Map<HexKey, PrivateChannelKey> = entry.privateChannels.associateBy { it.channelId }
private fun privatePlanes(
channelIdHex: HexKey,
held: PrivateChannelKey,
): ConcordChannelPlanes {
val plane =
ConcordChannelPlane(
channelIdHex,
held.epoch,
ConcordActions.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch),
)
return ConcordChannelPlanes(channelIdHex, isPrivate = true, write = plane, reads = listOf(plane))
}
private fun publicPlanes(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): ConcordChannelPlanes {
val idBytes = channelIdHex.hexToByteArray()
val write =
ConcordChannelPlane(
channelIdHex,
entry.rootEpoch,
ConcordActions.publicChannel(entry.root.hexToByteArray(), idBytes, entry.rootEpoch),
)
// Prior epochs the account still holds a root for: a Refounding rotates the root, so
// pre-refounding history lives on a different address per epoch. Bounded — every covered
// epoch multiplies the subscription + AUTH footprint by the channel count.
val older =
entry.heldRoots
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
.map { held ->
ConcordChannelPlane(
channelIdHex,
held.epoch,
ConcordActions.publicChannel(held.key.hexToByteArray(), idBytes, held.epoch),
)
}
// heldRoots may or may not include the current root depending on how the entry was written
// (stranded recovery folds it in), so dedupe by address and keep the write plane first.
val reads = (listOf(write) + older).distinctBy { it.key.publicKeyHex }
return ConcordChannelPlanes(channelIdHex, isPrivate = false, write = write, reads = reads)
}
}
@@ -102,36 +102,30 @@ object ConcordSubscriptionPlanner {
}
/**
* Chat-plane subscriptions for every live channel in a folded community [state] at the current
* epoch, plus each channel's plane at every prior epoch the account still holds a root for
* ([ConcordCommunityListEntry.heldRoots]). A CORD-06 Refounding rotates the root per epoch, so the
* pre-refounding history lives under those prior-epoch planes; subscribing to them is what lets the
* client fetch messages older than the last Refounding instead of stopping at "All caught up".
* Chat-plane subscriptions for every channel of a folded community [state] this account can open
* each channel's current address plus, for a public channel, its plane at every prior epoch the
* account still holds a root for ([ConcordCommunityListEntry.heldRoots]). A CORD-06 Refounding
* rotates the root per epoch, so the pre-refounding history lives under those prior-epoch planes;
* subscribing to them is what lets the client fetch messages older than the last Refounding
* instead of stopping at "All caught up".
*
* Private channels are addressed by their own delivered key, and one whose key we don't hold is
* not subscribed at all see [ConcordChannelPlanner].
*/
fun channelPlaneSubs(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
): List<ConcordPlaneSub> {
val root = entry.root.hexToByteArray()
val relays = normalize(entry.relays)
val current =
state.channels.keys.map { channelIdHex ->
val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, channelIdHex),
pubKeyHex = ch.publicKeyHex,
relays = relays,
)
}
val historical =
ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane ->
return ConcordChannelPlanner.channelPlanes(entry, state).flatMap { channel ->
channel.reads.map { plane ->
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
pubKeyHex = plane.key.publicKeyHex,
relays = relays,
)
}
return current + historical
}
}
/**
@@ -63,9 +63,12 @@ sealed interface PostingGate {
data object Dissolved : Blocked
/**
* We cannot place this account in the community at all no key, no role. Distinct from
* [NotAMember] in that there is no roster to join and no relay to ask: in Concord, holding the
* key *is* membership.
* We hold no key for what we are looking at either the community itself (no key, no role) or
* this specific private channel, whose per-member key was never delivered to us (CORD-03 §1).
*
* Distinct from [NotAMember] in that there is no roster to join and no relay to ask: in Concord,
* holding the key *is* membership. Normally unreachable, because a channel we cannot derive is
* not displayed at all this is the backstop for arriving at one anyway.
*/
data object NoKey : Blocked
@@ -79,6 +79,18 @@ class ConcordChannel(
var membership: ConcordMembership = ConcordMembership.MEMBER
private set
/**
* Whether this account holds the key that addresses **this channel's** plane. Always true for a
* public channel (the community root derives it); for a private channel it means the per-member
* key was delivered in our kind-13302 bundle (CORD-03 §1).
*
* Optimistic until a fold says otherwise, matching [membership] a channel we can't open is
* normally never displayed at all, so this is the backstop for arriving at one anyway (a stale
* route, a link) rather than the primary gate.
*/
var holdsKey: Boolean = true
private set
/**
* True once the community has been dissolved by an owner-signed tombstone (CORD-02 §9). On sight
* the client seals the Community read-only: held keys still open history, but nothing new is
@@ -111,6 +123,7 @@ class ConcordChannel(
state: ConcordCommunityState,
relays: Set<NormalizedRelayUrl>,
myPubKey: HexKey,
holdsChannelKey: Boolean = true,
): Boolean {
val def = state.channels[channelId.channelId]?.definition
// Channel fields keep their prior value until the channel edition folds.
@@ -131,6 +144,7 @@ class ConcordChannel(
communityIcon != newCommunityIcon ||
communityBanner != newCommunityBanner ||
membership != newMembership ||
holdsKey != holdsChannelKey ||
dissolved != newDissolved
channelName = newChannelName
@@ -141,6 +155,7 @@ class ConcordChannel(
communityBanner = newCommunityBanner
communityRelays = relays
membership = newMembership
holdsKey = holdsChannelKey
dissolved = newDissolved
return changed
}
@@ -162,8 +177,10 @@ class ConcordChannel(
when {
dissolved -> PostingGate.Dissolved
membership == ConcordMembership.BANNED -> PostingGate.Banned
// NONE — no key and no role, so there is nothing to place this account by.
!membership.isMember() -> PostingGate.NoKey
// Either we can't be placed in the community at all (NONE — no key, no role), or this is
// a private channel whose per-member key we were never granted. Both mean the plane we
// would publish to isn't ours to derive.
!membership.isMember() || !holdsKey -> PostingGate.NoKey
else -> PostingGate.Allowed
}
@@ -21,6 +21,9 @@
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlane
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanes
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanner
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
@@ -178,14 +181,22 @@ class ConcordCommunitySession(
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
// channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold.
private var channelKeysByAddress = HashMap<HexKey, Pair<HexKey, GroupKey>>()
// channel plane pubkey -> the channel's CURRENT write/read plane, refreshed on each control
// re-fold. The epoch rides along rather than being read off the community: a private channel is
// bound to its own channel epoch, not the root epoch (CORD-03 §1), so taking the epoch from the
// entry would reject every private wrap on arrival.
private var channelKeysByAddress = HashMap<HexKey, ConcordChannelPlane>()
// Prior-epoch channel plane pubkey -> (channelIdHex, key, epoch), for pre-Refounding history.
// A CORD-06 Refounding rotates the root per epoch, so older messages live under a different
// plane per held root; we re-derive those here so historical wraps are subscribed, AUTHed, and
// decrypted alongside the current epoch. Empty when the account holds no prior roots.
private var historicalChannelKeysByAddress = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
private var historicalChannelKeysByAddress = HashMap<HexKey, ConcordChannelPlane>()
// Every channel of the current fold this account can open, in fold order. A private channel whose
// per-member key was never delivered to us is absent: its plane is not derivable, so there is
// nothing to subscribe, decrypt or publish — and nothing to show.
private var openableChannels = listOf<ConcordChannelPlanes>()
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
val state: StateFlow<ConcordCommunityState?> = _state
@@ -254,8 +265,17 @@ class ConcordCommunitySession(
*/
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys + historicalChannelKeysByAddress.keys }
/**
* The channel ids this account can actually open, in fold order every folded channel except a
* private one whose key we hold no bundle entry for.
*
* Display surfaces iterate this instead of `state.channels`: a channel we cannot derive shows an
* unopenable room and (before the plane resolver existed) invited a post onto the wrong plane.
*/
fun openableChannelIds(): List<HexKey> = lock.withLock { openableChannels.map { it.channelIdHex } }
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key }
/**
* Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each
@@ -266,8 +286,8 @@ class ConcordCommunitySession(
*/
fun channelPlaneAddressesAllEpochs(channelIdHex: HexKey): List<HexKey> =
lock.withLock {
val current = channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key
val historical = historicalChannelKeysByAddress.entries.filter { it.value.first == channelIdHex }.map { it.key }
val current = channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key
val historical = historicalChannelKeysByAddress.entries.filter { it.value.channelIdHex == channelIdHex }.map { it.key }
(listOfNotNull(current) + historical)
}
@@ -296,9 +316,9 @@ class ConcordCommunitySession(
// Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the
// gated relays must serve their wraps too.
historicalControlKeys.values.map { it.first } +
channelKeysByAddress.values.map { it.second } +
channelKeysByAddress.values.map { it.key } +
// Prior-epoch channel stream keys so the gated relays serve their older wraps too.
historicalChannelKeysByAddress.values.map { it.second }
historicalChannelKeysByAddress.values.map { it.key }
}
/** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */
@@ -370,15 +390,13 @@ class ConcordCommunitySession(
}
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
if (current != null) {
val (channelIdHex, key) = current
return ingestChannelWrap(wrap, channelIdHex, key, entry.rootEpoch, seenOnRelays)
return ingestChannelWrap(wrap, current.channelIdHex, current.key, current.epoch, seenOnRelays)
}
// A prior-epoch plane (pre-Refounding history). Decrypt with that epoch's key and
// bind-check against that epoch. Keyed separately from the current buffer so a re-fold
// (which rebuilds only the current-epoch keys) never re-projects the historical ones.
val historical = lock.withLock { historicalChannelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE
val (channelIdHex, key, epoch) = historical
return ingestChannelWrap(wrap, channelIdHex, key, epoch, seenOnRelays)
return ingestChannelWrap(wrap, historical.channelIdHex, historical.key, historical.epoch, seenOnRelays)
}
}
}
@@ -452,25 +470,31 @@ class ConcordCommunitySession(
controlFloorsLocked(),
)
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first }
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
for (channelIdHex in folded.channels.keys) {
val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
next[key.publicKeyHex] = channelIdHex to key
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.channelIdHex }
// One resolver decides which secret addresses each channel — the root for a public
// channel, the bundle's delivered key for a private one — and drops any private
// channel we hold no key for. Channels are known only after a fold, hence here.
val planes = ConcordChannelPlanner.channelPlanes(entry, folded)
openableChannels = planes
val next = HashMap<HexKey, ConcordChannelPlane>()
val historical = HashMap<HexKey, ConcordChannelPlane>()
for (channel in planes) {
next[channel.write.key.publicKeyHex] = channel.write
// Prior-epoch planes hold pre-Refounding history: subscribed, AUTHed and decrypted
// alongside the current one, but kept apart so a re-fold (which rebuilds only the
// current-epoch keys) never re-projects them.
for (plane in channel.reads) {
if (plane.key.publicKeyHex != channel.write.key.publicKeyHex) {
historical[plane.key.publicKeyHex] = plane
}
}
}
channelKeysByAddress = next
// Re-derive the prior-epoch planes for the same (epoch-invariant) channel ids, so older
// pre-Refounding history is subscribed/AUTHed/decrypted. Channels are known only after a
// fold, hence derived here rather than up front.
val historical = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
for (plane in ConcordActions.historicalChannelPlanes(entry.heldRoots, folded.channels.keys)) {
historical[plane.key.publicKeyHex] = Triple(plane.channelIdHex, plane.key, plane.epoch)
}
historicalChannelKeysByAddress = historical
_state.value = folded
folded.channels.keys.filterNot { it in prevChannels }
planes.map { it.channelIdHex }.filterNot { it in prevChannels }
}
// Project only channels appearing for the first time. Existing channels' wraps were already
@@ -533,9 +557,9 @@ class ConcordCommunitySession(
* re-fold (keys may change). Prior-epoch wraps in the buffer simply won't open under the current
* key and are skipped they were already emitted when they landed (the sink dedups by id). */
private fun reprojectChannel(channelIdHex: HexKey) {
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
val plane = lock.withLock { channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex } } ?: return
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps)
emitChannelRumors(channelIdHex, plane.key, plane.epoch, wraps)
}
/**
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanner
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
@@ -81,17 +82,23 @@ class ConcordPlaneRegistry {
}
}
/** Registers the Chat Plane address of every channel in a folded community [state]. */
/**
* Registers every Chat Plane address of every channel in a folded community [state] this account
* can open across held epochs for a public channel, and at its own epoch for a private one.
*
* A private channel whose key we hold no bundle entry for registers **nothing**: the address is
* not derivable from the community root, and deriving it from the root anyway (as this used to)
* put private traffic on a plane every member could read and write.
*/
fun registerChannels(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
) = lock.withLock {
val root = entry.root.hexToByteArray()
for (channelIdHex in state.channels.keys) {
val ch =
com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch)
for (channel in ConcordChannelPlanner.channelPlanes(entry, state)) {
for (plane in channel.reads) {
planes[plane.key.publicKeyHex] =
ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channel.channelIdHex), plane.key)
}
}
}
@@ -0,0 +1,199 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Which secret addresses a Concord channel (CORD-03 §1).
*
* The bug this pins down: every call site used to derive *every* channel's plane from the shared
* `community_root`, including `private: true` ones. That put private traffic on an address any member
* of the community could derive so a private channel was readable and postable by people who were
* never granted its key and, symmetrically, made a correctly-addressed private channel from another
* client look permanently empty.
*/
class ConcordChannelPlannerTest {
private val owner = "0f".repeat(32)
private val communityId = "1c".repeat(32)
private val root = "aa".repeat(32)
private val priorRoot = "ab".repeat(32)
private val publicChannelId = "c1".repeat(32)
private val privateChannelId = "c2".repeat(32)
private val privateKey = "77".repeat(32)
private val rootEpoch = 3L
private val privateEpoch = 5L
private fun ed(
kind: ControlEntityKind,
eid: String,
content: String,
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, owner, "r-$eid", 0)
/** A fold with one public channel and one private channel. */
private fun state(): ConcordCommunityState =
ConcordCommunityState.fold(
listOf(
ed(ControlEntityKind.CHANNEL, publicChannelId, """{"name":"general"}"""),
ed(ControlEntityKind.CHANNEL, privateChannelId, """{"name":"mods","private":true}"""),
),
owner,
)
private fun entry(
privateChannels: List<PrivateChannelKey> = emptyList(),
heldRoots: List<HeldRoot> = emptyList(),
) = ConcordCommunityListEntry(
id = communityId,
owner = owner,
ownerSalt = "01".repeat(32),
root = root,
rootEpoch = rootEpoch,
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = listOf("wss://r.example"),
)
private val heldPrivate = PrivateChannelKey(privateChannelId, privateKey, privateEpoch, "mods")
@Test
fun publicChannelIsAddressedByTheCommunityRootAtTheRootEpoch() {
val planes = ConcordChannelPlanner.channelPlanesFor(entry(), state(), publicChannelId)!!
assertEquals(false, planes.isPrivate)
assertEquals(rootEpoch, planes.write.epoch)
assertEquals(
ConcordActions.publicChannel(root.hexToByteArray(), publicChannelId.hexToByteArray(), rootEpoch).publicKeyHex,
planes.write.key.publicKeyHex,
)
}
@Test
fun privateChannelIsAddressedByItsOwnDeliveredKeyAtItsOwnEpoch() {
val planes = ConcordChannelPlanner.channelPlanesFor(entry(privateChannels = listOf(heldPrivate)), state(), privateChannelId)!!
assertEquals(true, planes.isPrivate)
// Its own epoch, not the community's — the wraps are bound to it, so taking the root epoch
// here would make every private message fail its bind check on arrival.
assertEquals(privateEpoch, planes.write.epoch)
assertEquals(
ConcordActions.privateChannel(privateKey.hexToByteArray(), privateChannelId.hexToByteArray(), privateEpoch).publicKeyHex,
planes.write.key.publicKeyHex,
)
// The regression: the root-derived address is a DIFFERENT plane, and must not be used.
assertNotEquals(
ConcordActions.publicChannel(root.hexToByteArray(), privateChannelId.hexToByteArray(), rootEpoch).publicKeyHex,
planes.write.key.publicKeyHex,
)
}
@Test
fun aPrivateChannelWeHoldNoKeyForIsOmittedEntirely() {
val entry = entry() // community member, but never granted the private channel's key
val planes = ConcordChannelPlanner.channelPlanes(entry, state())
assertEquals(listOf(publicChannelId), planes.map { it.channelIdHex })
assertNull(ConcordChannelPlanner.channelPlanesFor(entry, state(), privateChannelId))
// The important half: no write coordinate at all, so no send path can fall back to the root.
assertNull(ConcordChannelPlanner.writePlane(entry, state(), privateChannelId))
}
@Test
fun holdingTheKeyMakesThePrivateChannelAppear() {
val planes = ConcordChannelPlanner.channelPlanes(entry(privateChannels = listOf(heldPrivate)), state())
assertEquals(setOf(publicChannelId, privateChannelId), planes.map { it.channelIdHex }.toSet())
}
@Test
fun aBundleHeldPrivateChannelShowsBeforeItsDefinitionFolds() {
// A fresh join holds its private-channel keys before the Control Plane catches up; the room
// shouldn't vanish in the meantime. Also covers a null state (nothing folded at all).
val entry = entry(privateChannels = listOf(heldPrivate))
assertEquals(listOf(privateChannelId), ConcordChannelPlanner.channelPlanes(entry, null).map { it.channelIdHex })
val planes = ConcordChannelPlanner.channelPlanesFor(entry, null, privateChannelId)!!
assertEquals(true, planes.isPrivate)
assertEquals(privateEpoch, planes.write.epoch)
}
@Test
fun publicChannelReadsSpanHeldEpochsAndPrivateOnesDoNot() {
val entry =
entry(
privateChannels = listOf(heldPrivate),
heldRoots = listOf(HeldRoot(rootEpoch - 1, priorRoot)),
)
val planes = ConcordChannelPlanner.channelPlanes(entry, state()).associateBy { it.channelIdHex }
// Public: current epoch first, then the prior-epoch plane that holds pre-Refounding history.
val public = planes.getValue(publicChannelId)
assertEquals(listOf(rootEpoch, rootEpoch - 1), public.reads.map { it.epoch })
assertEquals(
public.write.key.publicKeyHex,
public.reads
.first()
.key.publicKeyHex,
)
assertEquals(
ConcordActions.publicChannel(priorRoot.hexToByteArray(), publicChannelId.hexToByteArray(), rootEpoch - 1).publicKeyHex,
public.reads[1].key.publicKeyHex,
)
// Private: exactly one plane. A per-channel rekey replaces the delivered key rather than
// accumulating epochs, and a held ROOT never derives a private channel's address.
val private = planes.getValue(privateChannelId)
assertEquals(listOf(privateEpoch), private.reads.map { it.epoch })
}
@Test
fun heldRootsContainingTheCurrentEpochDoNotDuplicateThePlane() {
// Stranded recovery folds the current root into heldRoots, so the same address can arrive
// twice; subscribing to it twice would just pad every REQ's author list.
val entry = entry(heldRoots = listOf(HeldRoot(rootEpoch, root), HeldRoot(rootEpoch - 1, priorRoot)))
val reads = ConcordChannelPlanner.channelPlanesFor(entry, state(), publicChannelId)!!.reads
assertEquals(reads.size, reads.distinctBy { it.key.publicKeyHex }.size)
assertEquals(listOf(rootEpoch, rootEpoch - 1), reads.map { it.epoch })
}
@Test
fun anUnknownChannelHasNoPlanes() {
assertNull(ConcordChannelPlanner.channelPlanesFor(entry(), state(), "ff".repeat(32)))
}
@Test
fun everyResolvedPlaneCarriesItsOwnChannelId() {
// The id travels with the plane because ingest routes an inbound wrap by address and needs to
// know which channel it landed in — a mismatch here would file messages under the wrong room.
val planes = ConcordChannelPlanner.channelPlanes(entry(privateChannels = listOf(heldPrivate)), state())
assertTrue(planes.all { channel -> channel.reads.all { it.channelIdHex == channel.channelIdHex } })
}
}
@@ -47,10 +47,10 @@ import kotlin.test.assertTrue
* Concord member got `canPost() == false` and an empty slot, because the only explanatory branch on
* the screen tested dissolution.
*
* [PostingGate.NoKey] has no producer to exercise here: it is the mapping for
* `ConcordMembership.NONE`, which the channel fold cannot currently reach (it derives membership for
* communities we already hold the key to). It exists so that enum value cannot silently resolve to
* "allowed".
* [PostingGate.NoKey] covers both "we can't place this account in the community" (the mapping for
* `ConcordMembership.NONE`, which the fold cannot currently reach) and "this is a private channel
* whose key we were never granted" — the second of which is real, and is the backstop for reaching
* such a channel despite it being hidden from every list.
*/
class PostingGateTest {
// ---- Concord ------------------------------------------------------------------------------
@@ -110,6 +110,18 @@ class PostingGateTest {
assertFalse(channel.canPost())
}
@Test
fun aPrivateChannelWeHoldNoKeyForExplainsItselfInsteadOfOfferingAComposer() {
// Such a channel is normally omitted from every list (ConcordChannelPlanner drops it), so this
// is the arrive-anyway path: a stale route must not present a composer whose message would
// have nowhere valid to go.
val channel = ConcordChannel(ConcordChannelId(owner, channelIdHex))
channel.updateFrom(concordState(), emptySet(), owner, holdsChannelKey = false)
assertEquals(PostingGate.NoKey, channel.postingGate())
assertFalse(channel.canPost())
}
@Test
fun dissolutionOutranksAPersonalBan() {
// Both apply. Dissolution is the complete answer — it blocks everyone — so naming the ban too
+18 -8
View File
@@ -144,7 +144,7 @@ retrying can ever help):
| **Exists** | a non-tombstoned `CHANNEL` edition in the fold | Gated by `MANAGE_CHANNELS`. |
| **Deleted** | `deleted: true` | Terminal — the channel id is never reused. |
| **Voice** | `voice: true` | Audio channel (`Mic` icon); CORD-07 broker token + kind-23313 voice presence. |
| **Private** | `private: true` | Derived-key visibility: a separate plane key delivered per member in the kind-13302 entry's `privateChannels`. |
| **Private** | `private: true` | Derived-key visibility: a separate plane key delivered per member in the kind-13302 entry's `privateChannels`, at that channel's own epoch. Resolved by `ConcordChannelPlanner`; a member holding no key for it sees no channel at all. |
| **Typing** | ephemeral kind **23311** | Wired, with a staleness window. |
| **Posting allowed** | `membership.isMember() && !dissolved` | |
@@ -241,7 +241,7 @@ others), and **invisible** (nothing renders it at all).
| `dissolved` | The same `PostingGateNotice` replaces the composer, inside an opened channel only — not on the community row, the channel list, or the Messages inbox. (There is also **no write path** to dissolve: an owner cannot do it from Amethyst.) | partial |
| **stranded / excluded epoch** | nothing. `recoverStrandedConcordCommunities()` runs at startup, `Log.w` on failure. A stranded community renders as an ordinary *empty* one. | **invisible** |
| current epoch / `heldRoots` | nothing | **invisible** |
| `private` channel | `Lock` icon in the channel list — but the row navigates like any other into a permanently empty room, because the plane key is never derived (see §8.1). No "you don't hold this key" state. | misleading |
| `private` channel | `Lock` icon in the channel list, shown only to members who hold its key; others get no row (and no inbox row). Reaching one anyway explains itself via `PostingGate.NoKey`. | **shown** |
| `voice` channel | `Mic` icon + preview-line suppression in the list; the opened channel has no voice affordance | partial |
| **kicked** (3309) | nothing — unwired | **invisible** |
| Guestbook join/leave | feeds the members roster; the motion itself isn't shown | implied |
@@ -273,12 +273,22 @@ Statuses that exist in the protocol/Quartz layer with no client behavior behind
§8.18.2 are the two that look like defects rather than unfinished features; §8.78.8 are
UI-only (the state is tracked correctly, nothing tells the user):
1. **Concord private channels are listed but never opened.** `ConcordPlaneRegistry.registerChannels`
derives only `ConcordChannelKeys.publicChannel`; the per-member keys in
`ConcordCommunityListEntry.privateChannels` are parsed, round-tripped and carried through
stranded recovery, but `privateChannel(...)` is called **only from a Quartz test**. The row
renders with a `Lock` icon and navigates like any other channel — into a permanently empty
timeline, with no "you don't have the key" state either.
1. ~~**Concord private channels are listed but never opened.**~~ **Fixed.** The read *and* write
paths derived every channel — `private: true` included — from the `community_root`, at six
independent call sites. So a private channel was addressed on a plane every member of the
community could derive: readable **and postable** by people never granted its key, and
simultaneously invisible to any conformant client (which addresses it by the delivered key). Now
one resolver, `ConcordChannelPlanner`, decides which secret addresses a channel — the root for a
public one, the bundle's per-member `channel_key` at the channel's own epoch for a private one —
and every consumer (plane registry, subscription planner, session re-fold, five send paths,
channel list, Messages inbox) reads it. A private channel we hold no key for is **omitted
entirely**, matching Armada's `channelsView` ("omit rather than tease"); arriving at one anyway
yields `PostingGate.NoKey`. Note the epoch now travels with the plane: a private channel's wraps
bind to its channel epoch, so the old code would have rejected them even with the right key.
**Migration:** "private" channels created by earlier Amethyst builds have their history on the
root-derived plane, which is no longer read for a private channel. That history is orphaned by
design — keeping it would mean continuing to read (and legitimise) traffic on a plane the whole
community can write to.
2. **`RoleScope` is decode-only.** `RoleEntity.scope` distinguishes `{"kind":"server"}` from
`{"kind":"channel","channel_id":…}`, but `AuthorityResolver.effectivePermissions` unions
every held role's bits regardless of scope — and nothing else reads it. A channel-scoped