fix(concord): a readmission keeps what the held entry knew

A readmission built the community's List entry from scratch and carried
over only heldRoots. So it dropped the seed, channel_cuts (letting a key
cut before the ban come back), retired channel keys and old private
channel keys. It also wrote that entry with follow() from a snapshot
taken before the join's suspensions, which the List's own contract
forbids.

`ConcordDirectInviteInbox.readmittedEntry` now puts the new base (root,
epoch, control key, relays, name, join time) over the held entry. It
keeps every held root plus the one being left, the seed and entry
extras, and moves each re-delivered channel key through withChannelKey,
which keeps the replaced key in `priors`. The join writes it through the
List's read-modify-write.

Verified on the emulator: amy bans Phone Tester in a Private community,
refounds, unbans and re-invites. After Accept the channel shows both the
pre-ban message and the post-refound one, and the composer is back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 10:51:31 -04:00
co-authored by Claude Opus 5.5
parent 3906ef2441
commit a3ac36dab8
3 changed files with 112 additions and 7 deletions
@@ -195,11 +195,19 @@ class AccountConcordActions(
inviteCreator: HexKey? = null, inviteCreator: HexKey? = null,
inviteLabel: String? = null, inviteLabel: String? = null,
fetchedWraps: List<Event> = emptyList(), fetchedWraps: List<Event> = emptyList(),
readmit: Boolean = false,
): Boolean { ): Boolean {
// False when the List could not take the membership (not loaded, or every held fragment is // False when the List could not take the membership (not loaded, or every held fragment is
// full while others are missing, CORD-02 §8). Callers must say so: the community would // full while others are missing, CORD-02 §8). Callers must say so: the community would
// otherwise look joined now and be gone after a restart. // otherwise look joined now and be gone after a restart.
if (!persistConcordEntry(entry)) return false // A readmission merges into the entry the List holds at write time, keeping what it knew.
val persisted =
if (readmit) {
updateConcordEntry(entry.id) { cur -> ConcordDirectInviteInbox.readmittedEntry(cur, entry) } || persistConcordEntry(entry)
} else {
persistConcordEntry(entry)
}
if (!persisted) return false
// The session is built asynchronously from the Community List flow. Every wrap that reaches // The session is built asynchronously from the Community List flow. Every wrap that reaches
// the cache before it exists is kept as an unclaimed note, and the live subscription's copy // the cache before it exists is kept as an unclaimed note, and the live subscription's copy
// of the same wrap is then deduplicated away, so the community showed "No channels yet" // of the same wrap is then deduplicated away, so the community showed "No channels yet"
@@ -917,16 +925,12 @@ 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,
) )
if (!joinConcordCommunity(entry, creator, label, planeWraps)) return ConcordInviteResult.NotSaved // A readmission keeps the held entry's roots, cuts and keys (ConcordDirectInviteInbox.readmittedEntry).
if (!joinConcordCommunity(entry, creator, label, planeWraps, readmit = readmitting != null)) return ConcordInviteResult.NotSaved
return ConcordInviteResult.Joined(bundle.communityId) return ConcordInviteResult.Joined(bundle.communityId)
} }
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
@@ -390,6 +392,42 @@ class ConcordDirectInviteInbox(
heldState.authority.isStaff(opened.sender) && heldState.authority.isStaff(opened.sender) &&
!heldState.authority.isBanned(opened.sender) !heldState.authority.isBanned(opened.sender)
/**
* The List entry a readmission writes: [fresh]'s base (the new root, epoch, Control Plane key,
* relays, name and join time) on top of everything [held] knew. Kept from [held]: every root
* it held plus the one it is leaving (pre-ban history), its `seed` and entry extras such as
* `channel_cuts` (so a cut key never comes back), and its private channel keys, each moved to
* the key [fresh] delivers through [ConcordChannelKeyring.withChannelKey], which keeps the one
* it replaces in `priors`. Built fresh, a readmission lost all of that.
*
* Apply it to the entry the List holds inside the write, never to a snapshot from before
* the join's suspensions.
*/
fun readmittedEntry(
held: ConcordCommunityListEntry,
fresh: ConcordCommunityListEntry,
): ConcordCommunityListEntry {
val base =
ConcordCommunityListEntry(
id = held.id,
owner = held.owner,
ownerSalt = held.ownerSalt,
root = fresh.root,
rootEpoch = fresh.rootEpoch,
controlPk = fresh.controlPk,
// A staff write key belongs to its epoch; the new one arrives with a Grant.
controlRoot = null,
heldRoots = (held.heldRoots + HeldRoot(held.rootEpoch, held.root, held.controlPk, held.controlRoot)).distinctBy { it.epoch to it.key.lowercase() },
privateChannels = held.privateChannels,
relays = fresh.relays.ifEmpty { held.relays },
name = fresh.name.ifBlank { held.name },
addedAt = fresh.addedAt,
inviteRef = fresh.inviteRef ?: held.inviteRef,
residue = held.residue,
)
return fresh.privateChannels.fold(base) { entry, key -> ConcordChannelKeyring.withChannelKey(entry, key) ?: entry }
}
/** /**
* 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
@@ -25,8 +25,10 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel
@@ -511,4 +513,65 @@ class ConcordDirectInviteInboxTest {
assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(forged, held, banned, me.pubKey)) assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(forged, held, banned, me.pubKey))
assertTrue(ConcordDirectInviteInbox.visible(listOf(forged), listOf(held), heldStateOf = { banned }, me = me.pubKey).isEmpty()) assertTrue(ConcordDirectInviteInbox.visible(listOf(forged), listOf(held), heldStateOf = { banned }, me = me.pubKey).isEmpty())
} }
@Test
fun aReadmissionKeepsWhatTheHeldEntryKnew() =
runTest {
val c = community()
val chan = "a1".repeat(32)
val gone = "c3".repeat(32)
// Held while banned: a private channel key at epoch 1 with an older prior, a cut on another
// channel, and a root from before an earlier Refounding.
val withKey = assertNotNull(ConcordChannelKeyring.withChannelKey(heldEntryOf(c), PrivateChannelKey(chan, "10".repeat(32), 0, "mods")))
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(withKey, chan, "11".repeat(32), 1, retiredAt = 5))
val held = ConcordChannelKeyring.withCut(rotated, gone, 3)
val heldWithRoots =
ConcordCommunityListEntry(
id = held.id,
owner = held.owner,
ownerSalt = held.ownerSalt,
root = held.root,
rootEpoch = held.rootEpoch,
controlPk = held.controlPk,
heldRoots = listOf(HeldRoot(held.rootEpoch - 1, "55".repeat(32), null, null)),
privateChannels = held.privateChannels,
relays = held.relays,
name = held.name,
addedAt = 1,
inviteRef = held.inviteRef,
residue = held.residue,
)
// The owner's readmission at the next epoch re-delivers the channel at epoch 2.
val fresh =
ConcordCommunityListEntry(
id = c.communityIdHex,
owner = c.ownerPubKey,
ownerSalt = c.ownerSalt.toHexKey(),
root = "77".repeat(32),
rootEpoch = held.rootEpoch + 1,
controlPk = "88".repeat(32),
privateChannels = listOf(PrivateChannelKey(chan, "12".repeat(32), 2, "mods")),
relays = listOf("wss://new.example"),
name = "Renamed",
addedAt = 99,
)
val merged = ConcordDirectInviteInbox.readmittedEntry(heldWithRoots, fresh)
// The new base.
assertEquals("77".repeat(32), merged.root)
assertEquals(held.rootEpoch + 1, merged.rootEpoch)
assertEquals("88".repeat(32), merged.controlPk)
assertEquals(listOf("wss://new.example"), merged.relays)
assertEquals(99, merged.addedAt)
// Every root it held, the one it is leaving included, so pre-ban history stays readable.
assertEquals(setOf(held.rootEpoch - 1, held.rootEpoch), merged.heldRoots.map { it.epoch }.toSet())
// The channel moved to the delivered key, and both older keys still read their eras.
assertEquals(2, ConcordChannelKeyring.heldKey(merged, chan)?.epoch)
assertEquals(listOf(1L, 0L), ConcordChannelKeyring.historicalKeys(merged, chan).map { it.epoch })
// The cut survives: a stale key for that channel can never come back.
assertEquals(3L, ConcordChannelKeyring.cutsOf(merged)[gone])
// The anchor for stranded recovery is kept when the invite carries none.
assertEquals("anchor", merged.inviteRef)
}
} }