feat(concord): tell a stranded member they were left behind

A CORD-06 Refounding carries no recipient list, so a member left out of the
rekey set sits on a dead root while everyone else moves on. Every plane
address they still derive is dead, and a dead address returns nothing rather
than erroring — so the community rendered as an ordinary quiet one, with no
affordance anywhere that could hint otherwise. Recovery ran on a 15-minute
timer and logged.

Two things were missing, not one.

Detection. The rekey drain treated "no blob for me" as nothing to do. But the
rotation's 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 a direct signal, not an inference from silence. It is now
recorded, gated on ConcordRefounding.isCompleteRotation: the recipient blobs
are chunked, so on a partial fetch the absence of ours proves nothing, and
exclusion may only be concluded once chunk indices 0..total-1 are all in hand.

Reporting. ConcordCommunityHealth per community, rendered by
ConcordCommunityBanner above the feed on both the channel list and an open
channel — because these states explain what you are seeing, and the composer
slot at the bottom is the wrong place to explain an empty feed. Dissolution
moves here too; the composer notice could only ever show it to someone
already inside a channel.

The health value is a single value rather than a set of flags, so two banners
cannot stack — that is the difference between a status surface and chrome.

The recovery sweep's collapsed `continue`s now distinguish revoked / expired /
unreadable / no-anchor, so the copy can ask for a new invite when that is
genuinely the fix. A dead link is only reported once it is actually what
stands between us and getting back in; on a community we are current with it
costs nothing and would be alarming noise. While a way back exists the banner
shows progress, since recovery is automatic.

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 03:51:43 +00:00
parent 942a639a60
commit c13db181b8
11 changed files with 775 additions and 67 deletions
@@ -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 }
}
}
@@ -307,6 +307,11 @@ fun ConcordChannelListScreen(
}
},
) { padding ->
// 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.
@@ -316,7 +321,7 @@ fun ConcordChannelListScreen(
session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } }
}
if (channels.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
style = MaterialTheme.typography.bodyMedium,
@@ -330,7 +335,7 @@ fun ConcordChannelListScreen(
// 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),
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
) {
items(channels, key = { it.first }) { entry ->
@@ -368,6 +373,7 @@ fun ConcordChannelListScreen(
}
}
}
}
/**
* One channel row in the community's server view: its icon, name, a preview of the last message
@@ -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,
@@ -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,
)
}
}
}
}
+7
View File
@@ -345,6 +345,13 @@
<string name="concord_leave_owner_warning">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.</string>
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
<string name="concord_dissolved_banner">This community has been dissolved. Its history stays readable, but nothing new can be posted.</string>
<string name="concord_stranded_recovering">This community rotated its keys without you, so new messages can\'t reach you. Reconnecting with your invite link…</string>
<string name="concord_stranded_no_anchor">This community rotated its keys without you, and this membership has no invite link to reconnect with. Ask a member for a new invite.</string>
<string name="concord_catching_up">Catching up — reconnecting after a key rotation…</string>
<string name="concord_recovery_failed_revoked">This community rotated its keys without you, and your invite link has been revoked. Ask a member for a new invite.</string>
<string name="concord_recovery_failed_expired">This community rotated its keys without you, and your invite link has expired. Ask a member for a new invite.</string>
<string name="concord_recovery_failed_unreadable">This community rotated its keys without you, and your invite link can no longer be read. Ask a member for a new invite.</string>
<string name="concord_typing_one">%1$s is typing…</string>
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
<string name="concord_typing_many">Several people are typing…</string>
@@ -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<Event>,
baseRekey: GroupKey,
priorRoot: ByteArray,
rootEpoch: Long,
): Boolean = ConcordRefounding.isCompleteRotation(wraps, baseRekey, priorRoot, rootEpoch)
}
@@ -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<HexKey, MutableStateFlow<ConcordCommunityHealth>>()
/** This community's live health. Creating the flow on read lets the UI subscribe before any write. */
fun flowFor(communityId: HexKey): StateFlow<ConcordCommunityHealth> = 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<ConcordCommunityHealth> =
lock.withLock {
flows.getOrPut(communityId) { MutableStateFlow(ConcordCommunityHealth.Healthy) }
}
}
@@ -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))
}
}
+12 -4
View File
@@ -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).
@@ -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<Event>,
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<Int>()
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]),
@@ -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 {