fix(concord): a banned member re-invited after an unban can rejoin

A ban in a Private community Refounds, so an owner who later unbans a member and
re-invites them can only do it at a newer epoch. Armada drops a banned
community from the member's list, so there the re-invite is a plain one; we
keep it (read-only, with the banned notice), and the inbox hid every invite for
a held community except same-base key catch-ups — so the re-invite never showed
and the member could never come back.

A Direct Invite for a held community whose fold still bans us, at a newer
epoch, is now offered as a normal invite (acceptPlan → Readmit). Accepting runs
the shared join path, which re-checks the ban against the NEW epoch's roster
and fails closed, and keeps the roots already held so pre-ban history reads.
A non-banned member's held base still never moves on a bundle (CORD-06 §2).
amy's `concord accept` follows the same plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-29 22:09:38 -04:00
co-authored by Claude Opus 5.5
parent 2966487f64
commit ff7716f6a6
4 changed files with 88 additions and 6 deletions
@@ -765,6 +765,19 @@ object ConcordCommands {
} }
// The Join is attributed to the seal-verified sender, never the bundle's claim. // The Join is attributed to the seal-verified sender, never the bundle's claim.
DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label) DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label)
// Readmitted at a newer epoch after a ban: the join re-checks the ban there, and the
// roots we already held stay banked so the history from before the ban reads.
DirectInviteAcceptPlan.Readmit -> {
val code = joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label)
val old = heldSc
if (code == 0 && old != null) {
store.find(old.communityId)?.let { now ->
val banked = (now.heldRoots + old.heldRoots + StoredHeldRoot(old.rootEpoch, old.root, old.controlPk, old.controlRoot)).distinctBy { it.epoch to it.root.lowercase() }
store.upsert(now.copy(heldRoots = banked))
}
}
code
}
} }
} }
} }
@@ -788,6 +788,7 @@ class AccountConcordActions(
inviteRef: String?, inviteRef: String?,
inviteCreator: HexKey?, inviteCreator: HexKey?,
inviteLabel: String?, inviteLabel: String?,
readmitting: ConcordCommunityListEntry? = null,
): ConcordInviteResult { ): ConcordInviteResult {
val relays = servedBy val relays = servedBy
@@ -804,7 +805,7 @@ class AccountConcordActions(
account.concordChannelList.liveCommunities.value account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == bundle.communityId } .firstOrNull { it.id == bundle.communityId }
var rejoined: ConcordCommunityListEntry? = null var rejoined: ConcordCommunityListEntry? = null
if (held != null) { if (held != null && readmitting == null) {
val heldState = val heldState =
account.concordSessions account.concordSessions
.sessionFor(held.id) .sessionFor(held.id)
@@ -879,6 +880,11 @@ class AccountConcordActions(
privateChannels = ConcordActions.privateChannelKeysOf(bundle), privateChannels = ConcordActions.privateChannelKeysOf(bundle),
relays = bundle.relays, relays = bundle.relays,
name = bundle.name, name = bundle.name,
// A readmission keeps the roots it held, so the history from before the ban stays readable.
heldRoots =
readmitting
?.let { (it.heldRoots + HeldRoot(it.rootEpoch, it.root, it.controlPk, it.controlRoot)).distinctBy { r -> r.epoch to r.key.lowercase() } }
.orEmpty(),
addedAt = TimeUtils.nowMillis(), addedAt = TimeUtils.nowMillis(),
// Anchor for stranded recovery (null for a Direct Invite, which has no link). // Anchor for stranded recovery (null for a Direct Invite, which has no link).
inviteRef = inviteRef, inviteRef = inviteRef,
@@ -927,6 +933,7 @@ class AccountConcordActions(
?.state ?.state
?.value ?.value
}, },
me = account.signer.pubKey,
) )
}.stateIn(account.scope, SharingStarted.Eagerly, emptyList()) }.stateIn(account.scope, SharingStarted.Eagerly, emptyList())
@@ -1120,7 +1127,7 @@ class AccountConcordActions(
val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } } val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } }
if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
} }
DirectInviteAcceptPlan.Join -> DirectInviteAcceptPlan.Join, DirectInviteAcceptPlan.Readmit ->
joinValidatedConcordInvite( joinValidatedConcordInvite(
bundle = bundle, bundle = bundle,
servedBy = emptySet(), servedBy = emptySet(),
@@ -1128,6 +1135,7 @@ class AccountConcordActions(
// Attributed to the seal-verified sender (Armada), never the bundle's claim. // Attributed to the seal-verified sender (Armada), never the bundle's claim.
inviteCreator = opened.sender, inviteCreator = opened.sender,
inviteLabel = bundle.label, inviteLabel = bundle.label,
readmitting = held?.takeIf { plan == DirectInviteAcceptPlan.Readmit },
) )
} }
if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId) if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId)
@@ -90,6 +90,12 @@ sealed interface DirectInviteAcceptPlan {
/** A community we don't hold: run the shared join path. */ /** A community we don't hold: run the shared join path. */
data object Join : DirectInviteAcceptPlan data object Join : DirectInviteAcceptPlan
/**
* A held community whose fold still bans us, re-invited at a newer epoch (see [ConcordDirectInviteInbox.isReadmission]):
* run the shared join path over the held entry, which re-checks the ban at the new epoch.
*/
data object Readmit : DirectInviteAcceptPlan
/** /**
* A held community: store [entry] — the held one plus the newly granted Private Channel keys * A held community: store [entry] — the held one plus the newly granted Private Channel keys
* ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write * ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write
@@ -343,6 +349,7 @@ class ConcordDirectInviteInbox(
): DirectInviteAcceptPlan { ): DirectInviteAcceptPlan {
if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired
if (held == null) return DirectInviteAcceptPlan.Join if (held == null) return DirectInviteAcceptPlan.Join
if (isReadmission(held, heldState, opened.invite, me)) return DirectInviteAcceptPlan.Readmit
if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew
if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded
// Death wins every race (CORD-02 §9): a dissolved community takes no new keys. // Death wins every race (CORD-02 §9): a dissolved community takes no new keys.
@@ -354,6 +361,27 @@ class ConcordDirectInviteInbox(
return DirectInviteAcceptPlan.CatchUp(adopted, ids) return DirectInviteAcceptPlan.CatchUp(adopted, ids)
} }
/**
* True when [invite] readmits a member [heldState] still bans: a ban in a Private community
* Refounds, so an owner who later unbans and re-invites us can only do it at a newer epoch.
* Armada drops a banned community from the list, so there its re-invite is a plain one; we
* keep the banned community (read-only), and without this the re-invite was hidden and
* could never be accepted. A banned member has no live membership a bundle could hijack
* (the reason a bundle may never move a held base, CORD-06 §2), and the join re-checks the
* ban against the NEW epoch's roster, failing closed.
*/
fun isReadmission(
held: ConcordCommunityListEntry,
heldState: ConcordCommunityState?,
invite: CommunityInvite,
me: HexKey,
): Boolean =
heldState != null &&
!heldState.dissolved &&
heldState.authority.isBanned(me) &&
invite.communityId.equals(held.id, ignoreCase = true) &&
invite.rootEpoch > held.rootEpoch
/** /**
* What a UI shows out of [pending], given the communities this account already holds * What a UI shows out of [pending], given the communities this account already holds
* ([joined]) and the ones it left ([removedAt], community id → the Community List * ([joined]) and the ones it left ([removedAt], community id → the Community List
@@ -379,6 +407,7 @@ class ConcordDirectInviteInbox(
isFollowed: (HexKey) -> Boolean = { false }, isFollowed: (HexKey) -> Boolean = { false },
isHidden: (HexKey) -> Boolean = { false }, isHidden: (HexKey) -> Boolean = { false },
heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null }, heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null },
me: HexKey? = null,
): List<ConcordDirectInviteView> { ): List<ConcordDirectInviteView> {
val nowSecs = nowMs / 1000 val nowSecs = nowMs / 1000
val heldById = joined.associateBy { it.id.lowercase() } val heldById = joined.associateBy { it.id.lowercase() }
@@ -395,15 +424,16 @@ class ConcordDirectInviteInbox(
// a catch-up from a non-staff sender, for channels the fold doesn't know as Private, // a catch-up from a non-staff sender, for channels the fold doesn't know as Private,
// or into a dissolved community is refused by [acceptPlan], so it is not offered. // or into a dissolved community is refused by [acceptPlan], so it is not offered.
val heldState = held?.let { heldStateOf(it.id) } val heldState = held?.let { heldStateOf(it.id) }
val readmit = held != null && me != null && isReadmission(held, heldState, opened.invite, me)
val newChannels = val newChannels =
when { when {
held == null -> emptyList() held == null || readmit -> emptyList()
heldState == null -> ConcordInviteVend.catchUpChannelIds(held, opened.invite) heldState == null -> ConcordInviteVend.catchUpChannelIds(held, opened.invite)
heldState.dissolved -> emptyList() heldState.dissolved -> emptyList()
else -> ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender) else -> ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
} }
if (held != null && newChannels.isEmpty()) continue if (held != null && !readmit && newChannels.isEmpty()) continue
val catchUp = held != null val catchUp = held != null && !readmit
val base = communityId + "|" + opened.sender.lowercase() val base = communityId + "|" + opened.sender.lowercase()
val key = if (catchUp) base + "|" + newChannels.sorted().joinToString(",") else base val key = if (catchUp) base + "|" + newChannels.sorted().joinToString(",") else base
val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs), newChannels.toList(), sentAt, isFollowed(opened.sender)) val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs), newChannels.toList(), sentAt, isFollowed(opened.sender))
@@ -47,6 +47,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertSame import kotlin.test.assertSame
@@ -72,12 +73,13 @@ class ConcordDirectInviteInboxTest {
expiresAt: Long? = null, expiresAt: Long? = null,
channels: List<InviteChannel> = emptyList(), channels: List<InviteChannel> = emptyList(),
root: String = c.communityRoot.toHexKey(), root: String = c.communityRoot.toHexKey(),
epoch: Long = c.rootEpoch,
) = CommunityInvite( ) = CommunityInvite(
communityId = c.communityIdHex, communityId = c.communityIdHex,
owner = c.ownerPubKey, owner = c.ownerPubKey,
ownerSalt = c.ownerSalt.toHexKey(), ownerSalt = c.ownerSalt.toHexKey(),
communityRoot = root, communityRoot = root,
rootEpoch = c.rootEpoch, rootEpoch = epoch,
controlPk = c.controlPkHex, controlPk = c.controlPkHex,
channels = channels, channels = channels,
relays = listOf("wss://relay.example"), relays = listOf("wss://relay.example"),
@@ -473,4 +475,33 @@ class ConcordDirectInviteInboxTest {
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me)) val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me))
assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey)) assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey))
} }
@Test
fun aBannedMemberReInvitedAtANewerEpochIsOfferedAReadmission() =
runTest {
val c = community()
val held = heldEntryOf(c)
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, c.controlPlane, c.communityId, me.pubKey, editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane)
val banned = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
// The ban Refounded; the owner unbanned us and re-invited us at the new epoch.
val reInvite = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, root = "77".repeat(32), epoch = c.rootEpoch + 1)), me))
assertEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(reInvite, held, banned, me.pubKey))
val shown = ConcordDirectInviteInbox.visible(listOf(reInvite), listOf(held), heldStateOf = { banned }, me = me.pubKey)
assertEquals(1, shown.size)
assertFalse(shown.single().catchUp)
// Still banned at the SAME epoch: nothing to readmit into.
val sameEpoch = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c)), me))
assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(sameEpoch, held, banned, me.pubKey))
assertTrue(ConcordDirectInviteInbox.visible(listOf(sameEpoch), listOf(held), heldStateOf = { banned }, me = me.pubKey).isEmpty())
// Not banned: a newer-epoch bundle still never moves a held base (CORD-06 §2).
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(reInvite, held, stateOf(c), me.pubKey))
assertTrue(ConcordDirectInviteInbox.visible(listOf(reInvite), listOf(held), heldStateOf = { stateOf(c) }, me = me.pubKey).isEmpty())
// Dissolved: death wins.
assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(reInvite, held, banned.withDissolved(true), me.pubKey))
}
} }