fix(concord): audit fixes — memory, folding, concurrency, notifications, UI

Address the deep-audit findings across the new Concord code:

- H1: stop persisting kind-21059 ephemeral typing wraps as durable notes.
  EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was
  stored forever; drop it once the session has ingested it.
- H2: follow()/unfollow() now read the offline backup (entriesWithBackup),
  so a join racing the async backup load can no longer wipe the joined list.
- M1 (banlist): fold to the head (honors a chained unban) then union in
  authorized editions that aren't ancestors of the head — concurrent bans
  are healed without resurrecting an on-chain unban (CORD-06 down-only).
- M2: reproject only the newly-arrived channel wrap incrementally instead
  of re-decrypting the whole buffer per message (was O(n^2)); refold only
  projects newly-folded channels.
- M3: cancel a session's old state-watcher before replacing it on a
  Refounding rebuild (was a coroutine + session leak per rekey).
- M4: publish typing/state/members/observed-authors under the lock and
  make revision/observedAuthors updates atomic; clamp future-dated typing.
- M5: notification Concord bypass now requires the community to be one this
  account has currently joined (mirrors the Marmot guard).
- M6: Concord chat honors the "Messages in notifications" toggle.
- L1: carry NIP-30 emoji tags on minichat replies, image captions and
  custom-emoji reactions.
- C1: make the composer VM init() idempotent so recomposition can't wipe a
  picked image or an open suggestion list.
- C2/C3: ConcordHome channel rows and unread badges react to the channel's
  own notes flow instead of the global revision (no stale rows / flicker).
- C4: try/finally around mint-invite / create / save so a thrown call can't
  strand the button disabled.
- C5: gate the typing ticker on active heartbeats so an idle channel stops
  waking a 2s loop.

Adds regression tests for concurrent-ban union-heal and unauthorized bans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-15 00:37:13 +00:00
parent b6a238b71a
commit 3950323ba3
16 changed files with 284 additions and 99 deletions
@@ -2084,7 +2084,7 @@ class Account(
// A minichat reply is a kind-1111 thread comment; 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 ->
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now())
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
parent != null ->
ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
else ->
@@ -2111,7 +2111,9 @@ class Account(
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now())
// 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)
publishConcordWrap(entry, wrap)
return true
}
@@ -2187,7 +2189,10 @@ class Account(
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now())
// 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)
publishConcordWrap(entry, wrap)
return true
}
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
@@ -298,7 +299,17 @@ class GiftWrapEventHandler(
// the payload opens with a derived plane key, not our identity — so route
// them to the Concord read-path first. A recognized wrap is fully handled
// there (folded / re-projected) and must not fall through to the DM path.
if (account.concordSessions.ingest(event)) return
if (account.concordSessions.ingest(event)) {
// Concord typing heartbeats ride kind-21059 ephemeral wraps that arrive
// continuously while anyone in any joined community is composing. NIP-01
// ephemeral events (2000029999) must never be persisted; the session has
// already folded the state they carried, so drop the durable wrap note now
// to keep LocalCache from growing without bound.
if (event is EphemeralGiftWrapEvent) {
cache.unlinkAndRemove(listOf(eventNote))
}
return
}
if (event.recipientPubKey() != account.signer.pubKey) return
@@ -181,8 +181,13 @@ fun ConcordChannelListScreen(
onClick = {
minting = true
scope.launch {
inviteLink = account.mintConcordInvite(communityId)
minting = false
try {
inviteLink = account.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
minting = false
}
}
},
) {
@@ -301,10 +301,16 @@ private fun ConcordTypingIndicator(
val typingMap by session.typing.collectAsStateWithLifecycle()
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
LaunchedEffect(session) {
// Only tick while this channel actually has heartbeats, and stop once they've all aged out of
// the freshness window — an idle channel must not wake a 2s recomposition loop forever. A new
// heartbeat re-keys this effect (the map value changes) and restarts the fade.
LaunchedEffect(session, channelId, typingMap[channelId]) {
val perChannel = typingMap[channelId]
if (perChannel.isNullOrEmpty()) return@LaunchedEffect
while (true) {
delay(2000L)
nowSecs = TimeUtils.now()
if (perChannel.values.none { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS }) break
delay(2000L)
}
}
@@ -130,13 +130,17 @@ fun ConcordCreateScreen(
working = true
scope.launch {
val communityId =
accountViewModel.account.createConcordCommunity(
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
relays = relays.map { it.url },
icon = icon.value,
)
working = false
try {
accountViewModel.account.createConcordCommunity(
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
relays = relays.map { it.url },
icon = icon.value,
)
} finally {
// Always re-enable — a thrown create would otherwise strand the button.
working = false
}
if (communityId != null) nav.newStack(Route.ConcordServer(communityId))
}
},
@@ -174,15 +174,19 @@ fun ConcordEditScreen(
working = true
scope.launch {
val ok =
account.editConcordMetadata(
communityId = communityId,
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
icon = icon.value,
banner = banner.value,
relays = relays.map { it.url },
)
working = false
try {
account.editConcordMetadata(
communityId = communityId,
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
icon = icon.value,
banner = banner.value,
relays = relays.map { it.url },
)
} finally {
// Always re-enable — a thrown save would otherwise strand the button.
working = false
}
if (ok) nav.popBack()
}
},
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
@@ -230,7 +229,6 @@ fun ConcordHomeScreen(
def?.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
},
revision = revision,
hideIfRead = mode == ChannelExpand.UNREAD,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Concord(entry.id, ch.key)) },
@@ -270,15 +268,22 @@ private fun communityUnreadCount(
account: Account,
communityId: String,
channelKeys: Set<String>,
revision: Int,
): Int {
if (channelKeys.isEmpty()) return 0
// Keyed only on the channel set (not the global revision): each per-channel flow reacts to both
// its last-read marker AND the channel's own notes flow, so a folded message flips the badge
// without tearing down and restarting every flow on every unrelated fold (which reset the badge
// to 0 and made it flicker).
val flow =
remember(communityId, channelKeys, revision) {
remember(communityId, channelKeys) {
combine(
channelKeys.map { key ->
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)).map { lastRead ->
val last = LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key))
combine(
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)),
channel.flow().notes.stateFlow,
) { lastRead, state ->
val last = state.channel.lastNote?.createdAt() ?: 0L
if (last > lastRead) 1 else 0
}
},
@@ -301,7 +306,7 @@ private fun CommunityHeader(
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val iconModel = rememberConcordImageModel(iconPointer, accountViewModel)
val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys, revision)
val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys)
// Tap cycles CLOSED → UNREAD → OPEN → CLOSED, skipping the UNREAD peek when nothing is unread
// (so a quiet community never lands on an empty middle state).
val next =
@@ -412,14 +417,22 @@ private fun ConcordChannelRow(
channelKey: String,
channelName: String,
icon: MaterialSymbol,
revision: Int,
hideIfRead: Boolean,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val account = accountViewModel.account
val channel = remember(communityId, channelKey) { LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, channelKey)) }
val lastNote = remember(revision, channel) { channel?.lastNote }
// getOrCreate (not getIfExists): a channel folded in the control plane may have no message-buffer
// note yet, and caching that null for the row's lifetime would leave it perpetually blank. The
// channel's own notes flow then makes lastNote reactive, so the preview/unread dot appears the
// moment its first message folds in — without keying on the global revision (which flickered the
// whole row on every unrelated fold).
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
val channelState by channel
.flow()
.notes.stateFlow
.collectAsStateWithLifecycle()
val lastNote = channelState.channel.lastNote
val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle()
val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime
@@ -75,6 +75,10 @@ open class ConcordNewMessageViewModel : ViewModel() {
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
open fun init(accountVM: AccountViewModel) {
// Idempotent: the screen calls init() on every recomposition, and it recomposes often while
// paging history. Rebuilding uploadState/suggestion state each time would wipe a picked image
// mid-upload or reset an open @/emoji suggestion list, so only (re)build when the account changes.
if (::accountViewModel.isInitialized && this.accountViewModel === accountVM) return
this.accountViewModel = accountVM
this.account = accountVM.account
@@ -444,7 +444,19 @@ class NotificationFeedFilter(
// in a community I've joined is relevant whether or not I follow that member (fellow members
// usually aren't follows). The p-tag gate below still applies, so only genuine replies /
// reactions / mentions notify — general channel chatter that doesn't tag me never does.
val isConcord = it.inGatherers?.any { g -> g is ConcordChannel } == true
//
// A ConcordChannel gatherer alone isn't enough: notes live in the global LocalCache and keep a
// gatherer reference from every account/community that ever touched them, so require the
// community to be one THIS account has currently joined (mirrors the Marmot check above) —
// otherwise a note from a prior account or a left community would leak onto Notifications.
val isConcord =
it.inGatherers?.any { g ->
g is ConcordChannel && account.concordSessions.sessionFor(g.channelId.communityId) != null
} == true
// Concord is a messaging feature, so honor the same "Messages in notifications" toggle that
// silences DMs and Marmot groups above.
if (isConcord && !showMessages) return false
// Global keeps every event that p-tags the user; Selected (and the
// follow/list modes) also applies the per-kind relevance heuristics.
@@ -174,8 +174,9 @@ object ConcordActions {
text: String,
imetas: List<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt)
val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
@@ -203,8 +204,9 @@ object ConcordActions {
parent: Event,
text: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt)
val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
@@ -217,8 +219,9 @@ object ConcordActions {
target: Event,
reaction: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt)
val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
@@ -103,15 +103,19 @@ class ConcordChannelListState(
/** Add or replace [entry] (by community id) and return the new signed list event to publish. */
suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent {
val current = getConcordList()?.decrypt(signer).orEmpty()
// Seed from the offline backup as well as the live cache event: the saved list is
// consumed into the cache asynchronously in `init`, so a join that races that load
// would otherwise start from an empty `current` and wipe every prior membership.
val current = entriesWithBackup(concordListNote)
val next = current.filterNot { it.id == entry.id } + entry
return ConcordCommunityListEvent.create(signer, next)
}
/** Drop the community with [communityId] and return the new list event, or null if none existed. */
suspend fun unfollow(communityId: String): ConcordCommunityListEvent? {
val event = getConcordList() ?: return null
val next = event.decrypt(signer).filterNot { it.id == communityId }
val current = entriesWithBackup(concordListNote)
if (current.none { it.id == communityId }) return null
val next = current.filterNot { it.id == communityId }
return ConcordCommunityListEvent.create(signer, next)
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* A validated inner chat rumor emitted by a session: its parent [communityId] and
@@ -258,10 +259,15 @@ class ConcordCommunitySession(
ingestTyping(wrap, channelIdHex, key)
return ConcordIngestOutcome.NON_STRUCTURAL
}
lock.withLock {
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap)
}
reprojectChannel(channelIdHex)
val isNew =
lock.withLock {
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
}
// Project only the newly-arrived wrap — the buffer's earlier wraps were already
// emitted when they landed, so re-decrypting the whole history on every message
// would be O(history) per message (quadratic over a channel's lifetime). A duplicate
// re-delivery (isNew == false) is a no-op.
if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap))
// A chat message lands in the feed via [onRumor] → LocalCache, independent of the
// revision; it changes no plane address, so it must NOT bump (see the storm note above).
return ConcordIngestOutcome.NON_STRUCTURAL
@@ -279,54 +285,77 @@ class ConcordCommunitySession(
val who = rumor.pubKey.lowercase()
if (who == myPubKey.lowercase()) return // never show my own typing back to me
val now = TimeUtils.now()
val snapshot =
lock.withLock {
val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() }
val prev = perChannel[who]
if (prev == null || rumor.createdAt > prev) perChannel[who] = rumor.createdAt
perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS }
if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex)
typingByChannel.mapValues { it.value.toMap() }
}
_typing.value = snapshot
// Update the map and publish inside the lock so a concurrent heartbeat on another
// channel can't publish an older snapshot last and drop this channel's typers.
lock.withLock {
val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() }
val prev = perChannel[who]
// Clamp a peer's heartbeat to our clock: a wildly future-dated createdAt would never
// fall out of the freshness window below and would block later real heartbeats.
val stamp = minOf(rumor.createdAt, now)
if (prev == null || stamp > prev) perChannel[who] = stamp
perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS }
if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex)
_typing.value = typingByChannel.mapValues { it.value.toMap() }
}
}
private fun refold() {
val wraps = lock.withLock { controlWraps.values.toList() }
val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner)
_state.value = folded
// Read the buffer, fold, re-derive channel keys, and publish state atomically under the
// lock so a concurrent control wrap can't publish a smaller fold last. Control editions
// are rare (not per-message), so serializing the fold is cheap.
val newChannels =
lock.withLock {
val wraps = controlWraps.values.toList()
val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner)
// Re-derive channel plane addresses from the fresh fold.
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
}
lock.withLock { channelKeysByAddress = next }
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
}
channelKeysByAddress = next
_state.value = folded
folded.channels.keys.filterNot { it in prevChannels }
}
// Any channel wraps already buffered can now project.
for (channelIdHex in folded.channels.keys) reprojectChannel(channelIdHex)
// Project only channels appearing for the first time. Existing channels' wraps were already
// emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so
// a channel's buffer never pre-dates its first fold) — re-projecting all channels on every
// control edition would be O(channels × history) of redundant decryption.
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
}
private fun refoldGuestbook() {
val wraps = lock.withLock { guestbookWraps.values.toList() }
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
lock.withLock {
val wraps = guestbookWraps.values.toList()
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
}
}
private fun reprojectChannel(channelIdHex: HexKey) {
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
// Decrypt + validate every bound rumor and hand it to the sink. The sink dedups
// by rumor id, so re-emitting the whole buffer on each fold is idempotent.
emitChannelRumors(channelIdHex, key, wraps)
}
/** Decrypt + validate the given [wraps], hand each bound rumor to the sink, and fold its author into
* the observed roster. The sink dedups by rumor id, so re-emitting a wrap is idempotent. */
private fun emitChannelRumors(
channelIdHex: HexKey,
key: GroupKey,
wraps: List<Event>,
) {
val authors = HashSet<HexKey>()
ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor ->
authors.add(rumor.pubKey.lowercase())
onRumor(entry.id, channelIdHex, rumor)
}
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
// roster even if they never posted a Guestbook Join. Only publish when the set actually grew.
if (authors.isNotEmpty() && !_observedAuthors.value.containsAll(authors)) {
_observedAuthors.value = _observedAuthors.value + authors
// roster even if they never posted a Guestbook Join. Atomic so a concurrent add isn't lost.
if (authors.isNotEmpty()) {
_observedAuthors.update { if (it.containsAll(authors)) it else it + authors }
}
}
@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
@@ -86,9 +87,14 @@ class ConcordSessionManager(
val departed = stateWatchers.keys.filterNot { it in wantedIds }
for (id in departed) stateWatchers.remove(id)?.cancel()
// Watch each newly-created session so its folds bump the revision.
// Watch each newly-created session so its folds bump the revision. A Refounding
// rebuilds a still-joined community's session in place (same id, new root/epoch),
// so it comes back in `created` while its old watcher is still running — cancel
// that stale collector before replacing the map entry, or every Refounding leaks
// a coroutine holding a dead session and bumping the revision forever.
for (id in created) {
val session = registry.sessionFor(id) ?: continue
stateWatchers.remove(id)?.cancel()
stateWatchers[id] =
scope.launch {
session.state.collect { bumpRevision() }
@@ -99,7 +105,10 @@ class ConcordSessionManager(
}
private fun bumpRevision() {
_revision.value = _revision.value + 1
// Called from the communities collector, every per-session state watcher, and the
// ingest path — different coroutines/dispatchers — so the increment must be atomic
// or concurrent bumps are lost.
_revision.update { it + 1 }
}
/** The `authors` set (control + known channel planes) for the kind-1059 subscription. */
@@ -121,11 +121,13 @@ object ChannelChat {
text: String,
parent: Event,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event =
RumorAssembler.assembleRumor(
authorPubKey,
CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) {
channelBinding(channelId, epoch)
extraTags.forEach { add(it) }
},
)
@@ -146,6 +148,7 @@ object ChannelChat {
targetKind: Int,
content: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event =
RumorAssembler.assembleRumor<ReactionEvent>(
pubKey = authorPubKey,
@@ -158,7 +161,7 @@ object ChannelChat {
arrayOf("e", targetId),
arrayOf("p", targetAuthor),
arrayOf("k", targetKind.toString()),
),
) + extraTags,
content = content,
)
@@ -177,6 +180,7 @@ object ChannelChat {
text: String,
imetas: List<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
@@ -186,7 +190,7 @@ object ChannelChat {
epoch = epoch,
text = finalText,
createdAt = createdAt,
extraTags = imetas.map { it.toTagArray() }.toTypedArray(),
extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags,
)
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
* Resolves the owner-rooted authority state of a Concord community from its
* folded Control Plane (CORD-04).
@@ -201,18 +203,53 @@ class AuthorityResolver private constructor(
return acc
}
// Banlist: honored only from a signer holding BAN (or the owner), then healed to the head.
val banHead =
EditionFold.foldEntity(
editions.filter {
it.entityKind == ControlEntityKind.BANLIST &&
(it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN))
},
)
// Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single
// replaced doc, so fold its chain to the head first — that honors a legitimate unban, which
// is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent
// forks: two moderators who ban different abusers at the same chain version fork the doc, and
// folding to one head would silently drop the other's ban. Union in every authorized edition
// that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed.
// Ancestors (superseded by the chain, including an unban's now-cleared target) are already
// reflected by the head and must not be resurrected. This is CORD-06's "down-only healing":
// a concurrent ban is never lost, while an on-chain unban still takes effect.
val authorizedBanlist =
editions.filter {
it.entityKind == ControlEntityKind.BANLIST &&
(it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN))
}
val banned = HashSet<String>()
banHead?.let { ConcordJson.decodeBanlist(it.content) }?.forEach { banned.add(it.lowercase()) }
val banHead = EditionFold.foldEntity(authorizedBanlist)
if (banHead != null) {
ConcordJson.decodeBanlist(banHead.content)?.forEach { banned.add(it.lowercase()) }
val ancestry = banlistAncestry(banHead, authorizedBanlist)
for (edition in authorizedBanlist) {
if (edition.hashHex !in ancestry) {
ConcordJson.decodeBanlist(edition.content)?.forEach { banned.add(it.lowercase()) }
}
}
}
return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned)
}
/**
* The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains
* from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by
* the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also
* terminates on any cycle.
*/
private fun banlistAncestry(
head: ControlEdition,
pool: List<ControlEdition>,
): Set<String> {
val byHash = pool.associateBy { it.hashHex }
val acc = HashSet<String>()
var cur: ControlEdition? = head
while (cur != null && acc.add(cur.hashHex)) {
val prev = cur.prevHash?.toHexKey()
cur = if (prev != null) byHash[prev] else null
}
return acc
}
}
}
@@ -67,18 +67,23 @@ class AuthorityResolverTest {
0,
)
private fun banlist(vararg banned: String) =
ControlEdition(
ControlEntityKind.BANLIST,
"44".repeat(32).hexToByteArray(),
0,
null,
null,
"[${banned.joinToString(",") { "\"$it\"" }}]",
owner,
"ban",
0,
)
private fun banlist(vararg banned: String) = banlistBy(owner, "ban", *banned)
private fun banlistBy(
author: String,
rumorId: String,
vararg banned: String,
) = ControlEdition(
ControlEntityKind.BANLIST,
"44".repeat(32).hexToByteArray(),
0,
null,
null,
"[${banned.joinToString(",") { "\"$it\"" }}]",
author,
rumorId,
0,
)
@Test
fun ranksPermissionsAndActionAuthorityAreOwnerRooted() {
@@ -152,6 +157,36 @@ class AuthorityResolverTest {
assertFalse(r.canActOn(alice, bob, BAN))
}
@Test
fun concurrentBansHealIntoAUnionAndAreNeverDropped() {
// Two authorized moderators ban different abusers at the same banlist version — a
// fork of the single banlist doc. Folding to one chain tip would silently drop the
// loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06
// down-only healing).
val heads =
listOf(
role(adminRole, adminJson),
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), // alice gains BAN
banlistBy(owner, "ban-owner", bob), // owner bans bob
banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol
)
val r = AuthorityResolver.resolve(heads, owner)
assertTrue(r.isBanned(bob))
assertTrue(r.isBanned(carol))
}
@Test
fun banlistEditionsFromUnauthorizedSignersAreIgnored() {
// carol holds no BAN permission, so her ban of dave must not take effect.
val heads =
listOf(
role(adminRole, adminJson),
banlistBy(carol, "ban-carol", dave),
)
val r = AuthorityResolver.resolve(heads, owner)
assertFalse(r.isBanned(dave))
}
@Test
fun deletedRolesAndPositionZeroAreDropped() {
val heads =