diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index 941368763b..171f539537 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -42,6 +42,8 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
+import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunityHealth
+import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunityHealthState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
@@ -659,6 +661,11 @@ class Account(
// 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.
+ // A dissolved community is community-wide and terminal, so the banner is the right place
+ // for it: the composer notice inside a channel only reaches someone already in one.
+ // Never cleared here — nothing un-dissolves, and a tick must not wipe a Stranded verdict.
+ if (state.dissolved) concordHealth.set(communityId, ConcordCommunityHealth.Dissolved)
+
val openable = session.openableChannelIds().toSet()
for (channelIdHex in state.channels.keys) {
val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
@@ -810,6 +817,13 @@ class Account(
// own chat bubbles.
val chatDeliveryTracker = ChatDeliveryTracker(client)
+ /**
+ * Per-community Concord health (stranded / catching up / recovery failed / dissolved), written by
+ * [drainConcordRekeys] and [recoverStrandedConcordCommunities] and read by the community banner.
+ * In-memory: a restart re-derives it from the next rekey drain or recovery sweep.
+ */
+ val concordHealth = ConcordCommunityHealthState()
+
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
@@ -2976,14 +2990,37 @@ class Account(
val wraps = session.pendingBaseRekeyWraps()
if (wraps.isEmpty()) continue
val entry = session.entry
+ val baseRekey = session.nextBaseRekeyKey()
+ val priorRoot = entry.root.hexToByteArray()
val received =
ConcordActions.openBaseRekey(
wraps = wraps,
- baseRekey = session.nextBaseRekeyKey(),
+ baseRekey = baseRekey,
recipientSigner = signer,
- priorRoot = entry.root.hexToByteArray(),
+ priorRoot = priorRoot,
rootEpoch = entry.rootEpoch,
- ) ?: continue
+ )
+ if (received == null) {
+ // A rotation we can see but were not given: every chunk is present and none carries a
+ // blob for us. That is the stranding signal, and it used to be dropped here — leaving
+ // the community looking merely quiet, because each plane address we still derive is
+ // dead and a dead address returns nothing rather than failing. The completeness guard
+ // matters: with a partial fetch, our blob's absence proves nothing.
+ if (ConcordActions.isCompleteBaseRotation(wraps, baseRekey, priorRoot, entry.rootEpoch)) {
+ Log.w("Concord", "Stranded: ${entry.id} excluded from the rotation to epoch ${entry.rootEpoch + 1}")
+ concordHealth.set(
+ entry.id,
+ ConcordCommunityHealth.Stranded(
+ strandedAtEpoch = entry.rootEpoch,
+ newEpoch = entry.rootEpoch + 1,
+ // The stored invite link is the only anchor that survives a rotation we
+ // weren't party to; without one, no automatic way back exists.
+ recoverable = entry.inviteRef != null,
+ ),
+ )
+ }
+ continue
+ }
if (received.newEpoch <= entry.rootEpoch) continue
val authority = session.state.value?.authority ?: continue
@@ -2992,6 +3029,25 @@ class Account(
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
+ // We were included after all (or a later rotation reached us): retract any banner.
+ concordHealth.clearIf(entry.id) { it !is ConcordCommunityHealth.Dissolved }
+ }
+ }
+
+ /**
+ * Escalates a stranded community to [ConcordCommunityHealth.RecoveryFailed].
+ *
+ * Gated on already knowing we are stranded: a dead invite link on a community we are current with
+ * costs us nothing today, and reporting it would be alarming and useless. It only matters once it
+ * is the thing standing between us and getting back in.
+ */
+ private fun reportConcordRecoveryFailure(
+ communityId: String,
+ reason: ConcordCommunityHealth.RecoveryFailed.Reason,
+ ) {
+ val current = concordHealth.currentFor(communityId)
+ if (current is ConcordCommunityHealth.Stranded || current is ConcordCommunityHealth.CatchingUp) {
+ concordHealth.set(communityId, ConcordCommunityHealth.RecoveryFailed(reason))
}
}
@@ -3030,12 +3086,22 @@ class Account(
if (!isWriteable()) return
val now = TimeUtils.nowMillis()
for (entry in concordChannelList.liveCommunities.value) {
- val inviteRef = entry.inviteRef ?: continue
+ val inviteRef = entry.inviteRef
+ if (inviteRef == null) {
+ // No anchor to re-resolve. Only worth saying when we already know we were left out —
+ // otherwise it is a latent risk, not a current problem, and belongs in no banner.
+ reportConcordRecoveryFailure(entry.id, ConcordCommunityHealth.RecoveryFailed.Reason.NO_ANCHOR)
+ continue
+ }
val last = lastConcordRecoveryCheck[entry.id]
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
lastConcordRecoveryCheck[entry.id] = now
- val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
+ val parsed = ConcordActions.parseInviteLink(inviteRef)
+ if (parsed == null) {
+ reportConcordRecoveryFailure(entry.id, ConcordCommunityHealth.RecoveryFailed.Reason.LINK_UNREADABLE)
+ continue
+ }
val relays =
(
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
@@ -3043,16 +3109,49 @@ class Account(
).toSet()
if (relays.isEmpty()) continue
+ // Only show progress once we know there is something to fix: re-resolving the link is a
+ // routine 15-minute check, and a banner on every tick would be noise.
+ val wasStranded = concordHealth.currentFor(entry.id) is ConcordCommunityHealth.Stranded
+ if (wasStranded) concordHealth.set(entry.id, ConcordCommunityHealth.CatchingUp(entry.rootEpoch))
+
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
- // Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
- val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
+ // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. Which
+ // kind of dead it is decides whether the user can do anything about it, so report it
+ // rather than collapsing every case into one silent `continue`.
+ val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)
+ val bundle = (status as? InviteBundleStatus.Live)?.invite
+ if (bundle == null) {
+ val reason =
+ when (status) {
+ is InviteBundleStatus.Revoked -> ConcordCommunityHealth.RecoveryFailed.Reason.LINK_REVOKED
+ is InviteBundleStatus.Expired -> ConcordCommunityHealth.RecoveryFailed.Reason.LINK_EXPIRED
+ is InviteBundleStatus.Unreadable -> ConcordCommunityHealth.RecoveryFailed.Reason.LINK_UNREADABLE
+ // Absent: nothing found on any relay, which is usually transient — leave the
+ // Stranded banner (or nothing) in place rather than claiming a dead link.
+ else -> null
+ }
+ if (reason != null) {
+ reportConcordRecoveryFailure(entry.id, reason)
+ } else if (wasStranded) {
+ // Put the stranded verdict back; catching-up implied progress we no longer have.
+ concordHealth.set(entry.id, ConcordCommunityHealth.Stranded(entry.rootEpoch, entry.rootEpoch + 1, recoverable = true))
+ }
+ continue
+ }
- val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
+ val merged = ConcordActions.recoverStranded(entry, bundle)
+ if (merged == null) {
+ // The link resolves and we are already at its epoch: nothing was missed after all.
+ if (wasStranded) concordHealth.clearIf(entry.id) { it is ConcordCommunityHealth.CatchingUp }
+ continue
+ }
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
+ // Caught up: the banner has nothing left to report.
+ concordHealth.clearIf(entry.id) { it !is ConcordCommunityHealth.Dissolved }
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt
index 1fe3ba646f..2be266c56d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt
@@ -307,62 +307,68 @@ 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 =
- 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(
- stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- } else {
- // Bottom room for the FAB, which the Scaffold's `padding` deliberately doesn't account for
- // (a FAB overlays content) — without it the last row's manager overflow menu sits under it
- // and can't be tapped. As contentPadding so rows scroll *through* the strip rather than the
- // viewport shrinking. Gated on [canManageChannels] because that is what renders the FAB:
- // a plain member has nothing to clear, and no reason to lose the space.
- LazyColumn(
- Modifier.fillMaxSize().padding(padding),
- contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
- ) {
- 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
- def.private == true -> MaterialSymbols.Lock
- else -> MaterialSymbols.Tag
- }
- val typingAuthors =
- remember(typingMap, typingNow, entry.first) {
- (typingMap[entry.first] ?: emptyMap())
- .filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS }
- .keys
- .sorted()
- }
- ConcordChannelListRow(
- communityId = communityId,
- channelKey = entry.first,
- channelName = name,
- icon = icon,
- isVoice = def.voice == true,
- typingAuthors = typingAuthors,
- canManageChannels = canManageChannels,
- accountViewModel = accountViewModel,
- onClick = { nav.nav(Route.Concord(communityId, entry.first)) },
- onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
- onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
+ // Community-wide status first: stranding and dissolution explain why the list below may be
+ // empty or frozen, so they belong above it rather than inside a channel.
+ Column(Modifier.padding(padding)) {
+ ConcordCommunityBanner(communityId, accountViewModel)
+
+ // 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 =
+ 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(), contentAlignment = Alignment.Center) {
+ Text(
+ stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
+ }
+ } else {
+ // Bottom room for the FAB, which the Scaffold's `padding` deliberately doesn't account for
+ // (a FAB overlays content) — without it the last row's manager overflow menu sits under it
+ // and can't be tapped. As contentPadding so rows scroll *through* the strip rather than the
+ // viewport shrinking. Gated on [canManageChannels] because that is what renders the FAB:
+ // a plain member has nothing to clear, and no reason to lose the space.
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
+ ) {
+ 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
+ def.private == true -> MaterialSymbols.Lock
+ else -> MaterialSymbols.Tag
+ }
+ val typingAuthors =
+ remember(typingMap, typingNow, entry.first) {
+ (typingMap[entry.first] ?: emptyMap())
+ .filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS }
+ .keys
+ .sorted()
+ }
+ ConcordChannelListRow(
+ communityId = communityId,
+ channelKey = entry.first,
+ channelName = name,
+ icon = icon,
+ isVoice = def.voice == true,
+ typingAuthors = typingAuthors,
+ canManageChannels = canManageChannels,
+ accountViewModel = accountViewModel,
+ 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)
+ }
}
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt
index 5c8338424b..a2253b3102 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt
@@ -211,6 +211,10 @@ fun ConcordChannelScreen(
.consumeWindowInsets(padding)
.imePadding(),
) {
+ // Above the feed, because what it reports explains the feed. A stranded community's
+ // channel is not empty-because-quiet, it is empty because every address we derive is dead.
+ ConcordCommunityBanner(communityId, accountViewModel)
+
Column(Modifier.fillMaxHeight().weight(1f, true)) {
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityBanner.kt
new file mode 100644
index 0000000000..a8caf0c562
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityBanner.kt
@@ -0,0 +1,163 @@
+/*
+ * 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.ui.screen.loggedIn.chats.publicChannels.concord
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunityHealth
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
+
+/**
+ * The community-wide status strip, directly under the app bar on a Concord community's screens.
+ *
+ * It exists because the states it reports explain **what you are seeing**, and the composer's slot at
+ * the bottom is the wrong place to explain an empty feed. The sharpest case is stranding: a member
+ * left out of a CORD-06 Refounding derives every plane address from a dead root, and a dead address
+ * returns nothing rather than erroring — so the community renders as an ordinary quiet one, with no
+ * affordance anywhere that could hint otherwise.
+ *
+ * One banner at a time, by construction: [ConcordCommunityHealth] is a single value rather than a set
+ * of flags, so there is no way to stack two and turn this into chrome. [ConcordCommunityHealth.Healthy]
+ * renders nothing — not an empty row, not padding.
+ */
+@Composable
+fun ConcordCommunityBanner(
+ communityId: String,
+ accountViewModel: AccountViewModel,
+) {
+ val health by
+ remember(communityId, accountViewModel) { accountViewModel.account.concordHealth.flowFor(communityId) }
+ .collectAsStateWithLifecycle()
+
+ when (val state = health) {
+ ConcordCommunityHealth.Healthy -> Unit
+
+ is ConcordCommunityHealth.Stranded ->
+ Banner(
+ // Recovery runs automatically on a timer, so while a way back exists the honest note is
+ // that it is being handled. Without an anchor it is a dead end and says so.
+ text =
+ if (state.recoverable) {
+ stringRes(R.string.concord_stranded_recovering)
+ } else {
+ stringRes(R.string.concord_stranded_no_anchor)
+ },
+ symbol = MaterialSymbols.Key,
+ container = MaterialTheme.colorScheme.secondaryContainer,
+ content = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+
+ is ConcordCommunityHealth.CatchingUp ->
+ Banner(
+ text = stringRes(R.string.concord_catching_up),
+ symbol = null,
+ container = MaterialTheme.colorScheme.secondaryContainer,
+ content = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+
+ is ConcordCommunityHealth.RecoveryFailed ->
+ Banner(
+ text =
+ stringRes(
+ when (state.reason) {
+ ConcordCommunityHealth.RecoveryFailed.Reason.LINK_REVOKED -> R.string.concord_recovery_failed_revoked
+ ConcordCommunityHealth.RecoveryFailed.Reason.LINK_EXPIRED -> R.string.concord_recovery_failed_expired
+ ConcordCommunityHealth.RecoveryFailed.Reason.LINK_UNREADABLE -> R.string.concord_recovery_failed_unreadable
+ ConcordCommunityHealth.RecoveryFailed.Reason.NO_ANCHOR -> R.string.concord_stranded_no_anchor
+ },
+ ),
+ symbol = MaterialSymbols.ErrorOutline,
+ container = MaterialTheme.colorScheme.errorContainer,
+ content = MaterialTheme.colorScheme.onErrorContainer,
+ )
+
+ ConcordCommunityHealth.Dissolved ->
+ Banner(
+ text = stringRes(R.string.concord_dissolved_banner),
+ symbol = MaterialSymbols.Lock,
+ container = MaterialTheme.colorScheme.surfaceVariant,
+ content = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/**
+ * A full-width strip under the app bar. A null [symbol] renders a spinner instead — the one state that
+ * is work in progress rather than a standing condition.
+ */
+@Composable
+private fun Banner(
+ text: String,
+ symbol: MaterialSymbol?,
+ container: Color,
+ content: Color,
+) {
+ Surface(color = container, modifier = Modifier.fillMaxWidth()) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
+ ) {
+ if (symbol != null) {
+ SymbolIcon(
+ symbol = symbol,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = content,
+ )
+ } else {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = content,
+ )
+ }
+ Column(Modifier.fillMaxWidth()) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = content,
+ )
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 73ae261bd1..0e5192df99 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -345,6 +345,13 @@
You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again.
Where this community\'s encrypted planes are published and read.
This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.
+ This community has been dissolved. Its history stays readable, but nothing new can be posted.
+ This community rotated its keys without you, so new messages can\'t reach you. Reconnecting with your invite link…
+ This community rotated its keys without you, and this membership has no invite link to reconnect with. Ask a member for a new invite.
+ Catching up — reconnecting after a key rotation…
+ This community rotated its keys without you, and your invite link has been revoked. Ask a member for a new invite.
+ This community rotated its keys without you, and your invite link has expired. Ask a member for a new invite.
+ This community rotated its keys without you, and your invite link can no longer be read. Ask a member for a new invite.
%1$s is typing…
%1$s and %2$s are typing…
Several people are typing…
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt
index dcf224e512..2a6ee1580d 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt
@@ -495,4 +495,16 @@ object ConcordActions {
priorRoot: ByteArray,
rootEpoch: Long,
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch)
+
+ /**
+ * Whether [wraps] hold **every** chunk of a complete base rotation off [rootEpoch] — the guard that
+ * lets [openBaseRekey] returning null mean "I was excluded" rather than "the fetch is incomplete".
+ * See [ConcordRefounding.isCompleteRotation].
+ */
+ fun isCompleteBaseRotation(
+ wraps: List,
+ baseRekey: GroupKey,
+ priorRoot: ByteArray,
+ rootEpoch: Long,
+ ): Boolean = ConcordRefounding.isCompleteRotation(wraps, baseRekey, priorRoot, rootEpoch)
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealth.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealth.kt
new file mode 100644
index 0000000000..d1ea2154a8
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealth.kt
@@ -0,0 +1,149 @@
+/*
+ * 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.model.concord
+
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * What is currently wrong — or being fixed — with one joined Concord community, as one value.
+ *
+ * Everything here describes the community as a whole rather than a single channel, which is why it
+ * belongs above the feed instead of in the composer's slot ([com.vitorpamplona.amethyst.commons.model.chats.PostingGate]
+ * answers the narrower "may I post here"). A dissolved or stranded community explains an *empty
+ * feed*, and an explanation for an empty feed cannot live below it.
+ *
+ * Deliberately one value, not a set: a screen shows at most one of these. Two stacked banners is how
+ * a status surface turns into chrome that users learn to scroll past.
+ */
+sealed interface ConcordCommunityHealth {
+ /** Nothing to say. Renders no banner at all. */
+ data object Healthy : ConcordCommunityHealth
+
+ /**
+ * A CORD-06 Refounding happened without us: the rotation's chunks were all present at our
+ * next-epoch rekey address and none carried a blob for this account, so we are sitting on a dead
+ * root at [strandedAtEpoch] while the community moved to [newEpoch].
+ *
+ * This is the state that was previously invisible — the community simply looked quiet, because
+ * every plane address we derive is dead and a dead address returns nothing rather than erroring.
+ *
+ * [recoverable] is whether an automatic way back exists: the membership must carry the invite
+ * link it was joined through (`inviteRef`), which is the only anchor that survives a rotation we
+ * were not party to. Without it there is nothing to re-resolve and only a fresh invite helps.
+ */
+ data class Stranded(
+ val strandedAtEpoch: Long,
+ val newEpoch: Long,
+ val recoverable: Boolean,
+ ) : ConcordCommunityHealth
+
+ /**
+ * Recovery is running: we are re-resolving the stored invite link to pick up the root we missed.
+ * Transient and expected — the honest thing to show while it works is progress, not an error.
+ */
+ data class CatchingUp(
+ val fromEpoch: Long,
+ ) : ConcordCommunityHealth
+
+ /**
+ * We were left out and cannot get back in on our own: the invite link we joined through no longer
+ * resolves to a live bundle ([reason] says why). A human has to send a new invite.
+ */
+ data class RecoveryFailed(
+ val reason: Reason,
+ ) : ConcordCommunityHealth {
+ enum class Reason {
+ /** The owner retired the invite link (a `vsk=9` tombstone). */
+ LINK_REVOKED,
+
+ /** The link's `expires_at` has passed. */
+ LINK_EXPIRED,
+
+ /** The bundle is unreadable — wrong token, or a newer format than we parse. */
+ LINK_UNREADABLE,
+
+ /** The membership carries no invite link at all, so there is nothing to re-resolve. */
+ NO_ANCHOR,
+ }
+ }
+
+ /**
+ * The community was sealed read-only by an owner-signed tombstone (CORD-02 §9). Held keys still
+ * open the history; nothing new is honored.
+ *
+ * Also surfaced in the composer's place while a channel is open, but that only covers a channel
+ * you have already entered — the banner is what explains the community's own screens.
+ */
+ data object Dissolved : ConcordCommunityHealth
+}
+
+/**
+ * Per-community [ConcordCommunityHealth], written by the account's rekey drain and recovery sweep and
+ * read by the UI.
+ *
+ * Held outside `ConcordCommunitySession` on purpose: recovery outcomes outlive any one session
+ * instance (a successful merge replaces the entry and rebuilds the session, and a banner reporting
+ * that must survive that rebuild), and a community that failed to recover may have no working session
+ * at all.
+ */
+class ConcordCommunityHealthState {
+ private val lock = KmpLock()
+ private val flows = HashMap>()
+
+ /** This community's live health. Creating the flow on read lets the UI subscribe before any write. */
+ fun flowFor(communityId: HexKey): StateFlow = flowForInternal(communityId)
+
+ fun currentFor(communityId: HexKey): ConcordCommunityHealth = flowForInternal(communityId).value
+
+ fun set(
+ communityId: HexKey,
+ health: ConcordCommunityHealth,
+ ) {
+ flowForInternal(communityId).value = health
+ }
+
+ /**
+ * Clears a community back to [ConcordCommunityHealth.Healthy], but **only** if it currently holds
+ * [ifCurrently]. Used to retract a transient state (a catch-up that finished) without stomping a
+ * more important one that landed meanwhile.
+ */
+ fun clearIf(
+ communityId: HexKey,
+ ifCurrently: (ConcordCommunityHealth) -> Boolean,
+ ) {
+ val flow = flowForInternal(communityId)
+ if (ifCurrently(flow.value)) flow.value = ConcordCommunityHealth.Healthy
+ }
+
+ fun clear() =
+ lock.withLock {
+ flows.values.forEach { it.value = ConcordCommunityHealth.Healthy }
+ }
+
+ private fun flowForInternal(communityId: HexKey): MutableStateFlow =
+ lock.withLock {
+ flows.getOrPut(communityId) { MutableStateFlow(ConcordCommunityHealth.Healthy) }
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealthTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealthTest.kt
new file mode 100644
index 0000000000..469326541e
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealthTest.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.model.concord
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+/**
+ * The per-community banner state: one value at a time, and retractions that can't stomp a more
+ * important state that landed meanwhile.
+ *
+ * The single-value shape is the point. Stranding, dissolution and a dead invite link all explain the
+ * same thing — why the feed is empty or frozen — and stacking two banners is how a status surface
+ * becomes chrome users scroll past.
+ */
+class ConcordCommunityHealthTest {
+ private val alpha = "a1".repeat(32)
+ private val beta = "b2".repeat(32)
+
+ @Test
+ fun aCommunityStartsHealthyAndSubscribingDoesNotChangeThat() {
+ val state = ConcordCommunityHealthState()
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
+ assertEquals(ConcordCommunityHealth.Healthy, state.flowFor(alpha).value)
+ }
+
+ @Test
+ fun theFlowIsStableAcrossReadsSoTheUiCanSubscribeBeforeAnyWrite() {
+ // The banner collects on first composition, which routinely happens before the rekey drain
+ // has anything to say; both sides must share one flow instance or the update never arrives.
+ val state = ConcordCommunityHealthState()
+ val first = state.flowFor(alpha)
+ state.set(alpha, ConcordCommunityHealth.Dissolved)
+
+ assertSame(first, state.flowFor(alpha))
+ assertEquals(ConcordCommunityHealth.Dissolved, first.value)
+ }
+
+ @Test
+ fun communitiesAreIndependent() {
+ val state = ConcordCommunityHealthState()
+ state.set(alpha, ConcordCommunityHealth.Stranded(strandedAtEpoch = 3, newEpoch = 4, recoverable = true))
+
+ assertTrue(state.currentFor(alpha) is ConcordCommunityHealth.Stranded)
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(beta))
+ }
+
+ @Test
+ fun aFinishedCatchUpRetractsItselfWithoutClearingSomethingWorse() {
+ val state = ConcordCommunityHealthState()
+ state.set(alpha, ConcordCommunityHealth.CatchingUp(fromEpoch = 3))
+
+ // The recovery that started the catch-up finished: retract only the catch-up.
+ state.clearIf(alpha) { it is ConcordCommunityHealth.CatchingUp }
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
+
+ // Now the same retraction runs when the community has since been dissolved. Dissolution is
+ // terminal and was observed independently, so a late "recovery finished" must not erase it.
+ state.set(alpha, ConcordCommunityHealth.Dissolved)
+ state.clearIf(alpha) { it is ConcordCommunityHealth.CatchingUp }
+ assertEquals(ConcordCommunityHealth.Dissolved, state.currentFor(alpha))
+ }
+
+ @Test
+ fun adoptingARotationClearsEverythingExceptDissolution() {
+ // What the rekey drain does after successfully adopting a new root.
+ val state = ConcordCommunityHealthState()
+
+ state.set(alpha, ConcordCommunityHealth.Stranded(3, 4, recoverable = true))
+ state.clearIf(alpha) { it !is ConcordCommunityHealth.Dissolved }
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
+
+ state.set(alpha, ConcordCommunityHealth.RecoveryFailed(ConcordCommunityHealth.RecoveryFailed.Reason.LINK_REVOKED))
+ state.clearIf(alpha) { it !is ConcordCommunityHealth.Dissolved }
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
+
+ state.set(alpha, ConcordCommunityHealth.Dissolved)
+ state.clearIf(alpha) { it !is ConcordCommunityHealth.Dissolved }
+ assertEquals(ConcordCommunityHealth.Dissolved, state.currentFor(alpha))
+ }
+
+ @Test
+ fun strandingRecordsBothEpochsAndWhetherAWayBackExists() {
+ // recoverable=false is the dead end: no stored invite link means nothing to re-resolve, so the
+ // copy has to ask for a new invite rather than promise a reconnect.
+ val recoverable = ConcordCommunityHealth.Stranded(strandedAtEpoch = 7, newEpoch = 8, recoverable = true)
+ assertEquals(7, recoverable.strandedAtEpoch)
+ assertEquals(8, recoverable.newEpoch)
+
+ val dead = ConcordCommunityHealth.Stranded(strandedAtEpoch = 7, newEpoch = 8, recoverable = false)
+ assertEquals(false, dead.recoverable)
+ }
+
+ @Test
+ fun clearResetsEveryKnownCommunity() {
+ val state = ConcordCommunityHealthState()
+ state.set(alpha, ConcordCommunityHealth.Dissolved)
+ state.set(beta, ConcordCommunityHealth.Stranded(1, 2, recoverable = false))
+
+ state.clear()
+
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
+ assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(beta))
+ }
+}
diff --git a/docs/community-status-matrix.md b/docs/community-status-matrix.md
index 2638284423..6bff935978 100644
--- a/docs/community-status-matrix.md
+++ b/docs/community-status-matrix.md
@@ -113,7 +113,7 @@ to hold the bit **and strictly outrank** the target ("equal cannot act on equal"
|---|---|---|
| **Dissolved** | owner-only `DISSOLVED` tombstone (CORD-02 §9) | Terminal, one-way. The community seals **read-only for everyone including the owner**: held keys still open history, nothing new is honored. `ConcordChannel.canPost() = membership.isMember() && !dissolved`. The one carve-out — deleting your own past message — runs through the note menu, not the composer, so this gate never blocks it. |
| **Current epoch** | `rootEpoch` + `heldRoots` on the kind-13302 entry | Which epochs' history you can still derive. Prior-epoch roots are kept so pre-Refounding messages stay readable. |
-| **Stranded / excluded** | `excludedAtEpoch`, detected by `ConcordStrandedRecovery` | A Refounding carries **no recipient list**, so a member simply left out hears nothing and sits on a dead epoch forever. The only way back is re-resolving the membership's own `inviteRef` and finding a higher epoch — then `mergeForward` catches up while preserving the anchor and prior roots. |
+| **Stranded / excluded** | Detected live in the rekey drain (`isCompleteBaseRotation` + no blob for us); `excludedAtEpoch` records it after a merge | A Refounding carries **no recipient list**, but the rekey wraps sit at an address every current member derives, so an excluded member *can* see the rotation happen and find no blob addressed to them — which is the detection. The way back is re-resolving the membership's own `inviteRef` and finding a higher epoch; `mergeForward` then catches up while preserving the anchor and prior roots. |
| **Refounded out** | not receiving the new root | The only *real* eviction Concord has: cryptographic, not a flag. |
| **Kicked** | kind **3309** Guestbook kick, honored only from a `KICK` holder who outranks the target | Off-consensus and advisory. **Modeled in Quartz, not wired in the client** — nothing calls `Guestbook.kick`/`kickTarget`. |
| **Guestbook joined / left** | self-signed kind **3306** `join`/`leave` | Best-effort presence for member discovery, explicitly *not* authority. |
@@ -239,7 +239,7 @@ others), and **invisible** (nothing renders it at all).
| My own `OWNER`/`ADMIN` | only via affordances: the create-channel FAB, the Edit icon, the roster's promote/ban items | implied |
| My own `BANNED` | `PostingGateNotice` takes the composer's place and names the ban ("Past messages stay readable, but you can no longer post"). Was invisible: the only explanatory branch tested `dissolved`, so a banned member got an empty space and no reason. | **shown** |
| `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** |
+| **stranded / excluded epoch** | `ConcordCommunityBanner` under the app bar on both the channel list and an open channel, off `ConcordCommunityHealth`: reconnecting / caught up / a dead invite link naming which kind of dead. Detected directly at the rekey address — the rotation's chunks are all there and none carries our blob — not inferred from silence. | **shown** |
| current epoch / `heldRoots` | nothing | **invisible** |
| `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 |
@@ -316,8 +316,16 @@ UI-only (the state is tracked correctly, nothing tells the user):
says a resend could work — `rate-limited`, `error`, `auth-required` — not for `blocked` /
`restricted`, where the relay would repeat itself. This is a general fix: every relay refusal
on a chat message now reaches the sender, with a ban as one case.
-8. **Stranding is silent.** Recovery is automatic and log-only, so a member left out of a
- Refounding sees an ordinary empty community rather than "you were left behind on epoch N".
+8. ~~**Stranding is silent.**~~ **Fixed.** Two things were missing, not one. *Detection:* the rekey
+ drain treated "no blob for me" as nothing to do, when the rekey wraps sit at an address every
+ current member derives — so an excluded member can watch the rotation happen and find no blob
+ addressed to them. That is now the signal, gated on `ConcordRefounding.isCompleteRotation` so a
+ partial fetch (a relay cap, a dropped socket) can't be mistaken for exclusion. *Reporting:*
+ `ConcordCommunityHealth` per community, rendered by `ConcordCommunityBanner` above the feed —
+ stranded / catching up / recovery failed, plus dissolution, which the composer notice could only
+ show to someone already inside a channel. One value, not a set, so two banners cannot stack. The
+ recovery sweep's four collapsed `continue`s now distinguish revoked / expired / unreadable /
+ no-anchor, and a dead link is only reported once it is actually the thing blocking us.
9. **Buzz tenant moderation has no write path.** 9031 remove-member has no caller; 9032
change-role, 9040/9041 ban/unban and 9042/9043 timeout/untimeout have no builder call at
all. Only 9030 add-member is reachable (the "Add people" dialog).
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt
index 7a7bb34899..7e1661cc69 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -166,6 +167,51 @@ object ConcordRefounding {
}
}
+ /**
+ * Whether [wraps] contain **every chunk** of a complete base rotation to `rootEpoch + 1` that
+ * continues the [priorRoot] this member holds.
+ *
+ * This is the guard that turns [findNewRoot] returning null into a conclusion. A rotation's
+ * recipient blobs are chunked ([ConcordRekey.MAX_BLOBS_PER_CHUNK] each), so on a partial fetch —
+ * a relay result cap, a dropped socket, a chunk still in flight — the absence of this member's
+ * blob proves nothing. Only once chunk indices `0 until total` are all present does "my blob is
+ * not here" mean "I was left out".
+ *
+ * Returns false when no matching chunk is present at all: nothing was rotated as far as we know,
+ * which is different from being excluded from something that was.
+ */
+ fun isCompleteRotation(
+ wraps: List,
+ baseRekeyKey: GroupKey,
+ priorRoot: ByteArray,
+ rootEpoch: Long,
+ ): Boolean {
+ val newEpoch = rootEpoch + 1
+ val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
+ val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
+
+ var total = -1
+ val seen = HashSet()
+ for (wrap in wraps) {
+ val rumor = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey)?.rumor ?: continue
+ if (rumor.kind != ConcordRekey.KIND) continue
+ if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue
+ if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
+ if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue
+
+ val chunk = rumor.tags.fastFirstOrNull { it.size >= 3 && it[0] == ConcordRekey.TAG_CHUNK } ?: continue
+ val index = chunk[1].toIntOrNull() ?: continue
+ val chunkTotal = chunk[2].toIntOrNull() ?: continue
+ if (chunkTotal <= 0 || index < 0 || index >= chunkTotal) continue
+ // Chunks of one rotation agree on the total; a disagreement means we are looking at
+ // mixed or malformed input, so claim nothing.
+ if (total != -1 && total != chunkTotal) return false
+ total = chunkTotal
+ seen.add(index)
+ }
+ return total > 0 && seen.size == total
+ }
+
/**
* Receives a base rotation for the member behind [recipientSigner]: opens the
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt
index fa919e9b14..5cf63e8b93 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt
@@ -37,6 +37,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -89,6 +90,94 @@ class ConcordRefoundingTest {
assertNull(carolRoot) // removed member receives no blob
}
+ /**
+ * The guard that lets a client conclude it was excluded. [ConcordRefounding.findNewRoot] returning
+ * null is ambiguous on its own — "no blob for me" and "I haven't fetched the chunk my blob is in"
+ * look identical — so exclusion may only be announced once every chunk of the rotation is in hand.
+ */
+ @Test
+ fun aCompleteRotationIsWhatMakesExclusionProvable() =
+ runTest {
+ val community = ConcordCommunityFactory.create(owner, "Test", now)
+ val priorRoot = community.communityRoot
+ val build =
+ ConcordRefounding.build(
+ rotatorSigner = owner,
+ communityId = community.communityId,
+ priorRoot = priorRoot,
+ newRoot = newRoot,
+ rootEpoch = community.rootEpoch,
+ priorControlWraps = community.genesisWraps,
+ priorControlKey = community.controlPlane,
+ recipientsXOnly = listOf(alice.pubKey, bob.pubKey),
+ createdAt = now,
+ )
+ val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, community.communityId, build.newEpoch)
+
+ // Carol holds the whole rotation and finds no blob: she was left out, and can say so.
+ assertTrue(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, priorRoot, community.rootEpoch))
+ assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, priorRoot, community.rootEpoch))
+
+ // Having fetched nothing looks the same to findNewRoot, and must NOT read as exclusion.
+ assertFalse(ConcordRefounding.isCompleteRotation(emptyList(), baseRekeyKey, priorRoot, community.rootEpoch))
+ }
+
+ @Test
+ fun aRotationOffADifferentPriorRootOrEpochDoesNotCount() =
+ runTest {
+ val community = ConcordCommunityFactory.create(owner, "Test", now)
+ val build =
+ ConcordRefounding.build(
+ rotatorSigner = owner,
+ communityId = community.communityId,
+ priorRoot = community.communityRoot,
+ newRoot = newRoot,
+ rootEpoch = community.rootEpoch,
+ priorControlWraps = community.genesisWraps,
+ priorControlKey = community.controlPlane,
+ recipientsXOnly = listOf(alice.pubKey),
+ createdAt = now,
+ )
+ val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
+
+ // Same wraps, but checked as though we held a different root: the prevcommit no longer
+ // continues ours, so this is not a rotation of the epoch we are on.
+ val otherRoot = ByteArray(32) { 0x11 }
+ assertFalse(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, otherRoot, community.rootEpoch))
+ // And checked at the wrong epoch, the newepoch tag no longer matches.
+ assertFalse(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, community.communityRoot, community.rootEpoch + 5))
+ }
+
+ @Test
+ fun aRotationMissingOneOfItsChunksIsIncomplete() =
+ runTest {
+ // Enough recipients to force chunking, so a dropped chunk is a realistic partial fetch.
+ val extras = List(ConcordRekey.MAX_BLOBS_PER_CHUNK) { NostrSignerInternal(KeyPair()).pubKey }
+ val community = ConcordCommunityFactory.create(owner, "Test", now)
+ val priorRoot = community.communityRoot
+ val build =
+ ConcordRefounding.build(
+ rotatorSigner = owner,
+ communityId = community.communityId,
+ priorRoot = priorRoot,
+ newRoot = newRoot,
+ rootEpoch = community.rootEpoch,
+ priorControlWraps = community.genesisWraps,
+ priorControlKey = community.controlPlane,
+ recipientsXOnly = listOf(alice.pubKey) + extras,
+ createdAt = now,
+ )
+ val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, community.communityId, build.newEpoch)
+
+ assertTrue(build.rekeyWraps.size > 1, "expected the rotation to be chunked")
+ assertTrue(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, priorRoot, community.rootEpoch))
+
+ // Drop the chunk Alice's blob happens to be in and she looks excluded — which is exactly
+ // why the completeness check has to gate that conclusion.
+ val partial = build.rekeyWraps.drop(1)
+ assertFalse(ConcordRefounding.isCompleteRotation(partial, baseRekeyKey, priorRoot, community.rootEpoch))
+ }
+
@Test
fun compactedControlPlaneFoldsIdenticallyUnderNewRoot() =
runTest {