fix(cli): amy complies with a Kick against its account

The app leaves a community when an honored Kick names it (CORD-04 §6), but that
check runs on its revision tick and amy has none, so a kicked amy kept the
community and kept reading it.

`channels`, `send` and `read` now fold the Guestbook against the roster and,
when an honored Kick naming us postdates this membership, drop the community
from the local store and stop with `kicked`. The store records `addedAt` on
create, join and import, so a re-invite starts a new membership the old Kick
no longer applies to. The rule itself moves to ConcordActions.honoredKickAgainst,
shared with the app session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-29 20:01:01 -04:00
co-authored by Claude Opus 5.5
parent 87e2f5e945
commit 0ed87cd714
7 changed files with 90 additions and 14 deletions
@@ -47,6 +47,7 @@ object ConcordChannelCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val state = foldState(ctx, sc)
ConcordCommands.kickedGuard(ctx, dataDir, sc, state.authority)?.let { return it }
Output.emit(
mapOf(
"name" to state.metadata?.name,
@@ -87,6 +88,7 @@ object ConcordChannelCommands {
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
val state = foldState(ctx, sc)
ConcordCommands.kickedGuard(ctx, dataDir, sc, state.authority)?.let { return it }
if (state.dissolved) {
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
}
@@ -141,6 +143,7 @@ object ConcordChannelCommands {
ctx.prepare()
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val state = foldState(ctx, sc)
ConcordCommands.kickedGuard(ctx, dataDir, sc, state.authority)?.let { return it }
// A Private Channel is read only on its own key's plane (CORD-03 §1); --root/--epoch pick a
// root-derived plane and so apply to Public Channels only.
val privatePlane =
@@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
@@ -223,6 +225,7 @@ object ConcordCommands {
controlRoot = community.controlRoot.toHexKey(),
generalChannelId = community.generalChannelIdHex,
relays = relays,
addedAt = TimeUtils.nowMillis(),
),
)
@@ -313,6 +316,7 @@ object ConcordCommands {
// unrecoverable, so a list entry without one must not clear ours.
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
privateChannels = e.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
addedAt = maxOf(e.addedAt, prior?.addedAt ?: 0),
),
)
mapOf(
@@ -588,6 +592,8 @@ object ConcordCommands {
// The stranded-recovery anchor; blank for a Direct Invite, which has no link.
inviteRef = inviteRef,
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
// A (re-)join starts a new membership: a Kick from before it no longer applies.
addedAt = TimeUtils.nowMillis(),
)
ConcordStore(dataDir.concordFile).upsert(stored)
@@ -1134,6 +1140,43 @@ object ConcordCommands {
return if (ctx.publish(event, relays).values.any { it.accepted }) merged else null
}
/** Opens every Guestbook motion at [sc]'s current epoch (CORD-02 §5); empty when the plane can't be read. */
suspend fun guestbookEntriesOf(
ctx: Context,
sc: StoredCommunity,
): List<GuestbookEntry> =
runCatching {
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val relays = relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
ctx
.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true)
.mapNotNull { ConcordActions.guestbookEntry(it.second, gb) }
}.getOrDefault(emptyList())
/**
* Compliance with a Kick against this account (CORD-04 §6). Amethyst drains this on its
* revision tick; amy has no tick, so a command that folds the community is the moment to
* check. When the Guestbook, coalesced against [authority], carries an honored Kick naming us
* that postdates this membership, the community is dropped locally (as a Leave does) and the
* command stops with `kicked`. A re-invite re-joins. An unreadable Guestbook is no verdict.
*/
suspend fun kickedGuard(
ctx: Context,
dataDir: DataDir,
sc: StoredCommunity,
authority: AuthorityResolver,
): Int? {
val coalesced = Guestbook.coalesce(guestbookEntriesOf(ctx, sc), TimeUtils.nowMillis(), authority)
val kick = ConcordActions.honoredKickAgainst(coalesced, ctx.signer.pubKey, sc.owner, sc.addedAt) ?: return null
ConcordStore(dataDir.concordFile).remove(sc.communityId)
return Output.error(
"kicked",
"${kick.author} kicked this account from '${sc.name}' (CORD-04 §6), so it left the community locally; a new invite re-joins",
mapOf("community_id" to sc.communityId, "kicked_by" to kick.author),
)
}
fun notFound(handle: String): Int {
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
return 1
@@ -740,14 +740,7 @@ object ConcordModCommands {
private suspend fun guestbookMembersOf(
ctx: Context,
sc: StoredCommunity,
): Set<String> =
runCatching {
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val relays = ConcordCommands.relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() }
}.getOrDefault(emptySet())
): Set<String> = ConcordActions.projectGuestbook(ConcordCommands.guestbookEntriesOf(ctx, sc)).mapTo(HashSet()) { it.lowercase() }
/**
* Authors of every channel message we can decrypt. Most members never send a Guestbook motion,
@@ -67,6 +67,10 @@ data class StoredCommunity(
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
val pendingRefounding: StoredPendingRefounding? = null,
// When this membership began (ms, the Community List's `added_at`). A Kick older than this
// judged an earlier membership that a re-join put behind us (CORD-04 §6). 0 = unknown, stored
// before amy tracked it: then any honored Kick that is still our latest Guestbook motion counts.
val addedAt: Long = 0,
)
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
@@ -122,6 +126,9 @@ class ConcordStore(
save(next)
}
/** Drop [communityId] from the list, as a Leave or a complied-with Kick does locally. */
fun remove(communityId: String) = save(load().filterNot { it.communityId == communityId })
/** Resolve a user-supplied handle: exact name, exact id, or a unique id/name prefix. */
fun find(handle: String): StoredCommunity? {
val all = load()
@@ -948,6 +948,22 @@ object ConcordActions {
?.rumor
?.let { Guestbook.parse(it) }
/**
* The honored Kick naming [me] in a [coalesced] Guestbook (CORD-04 §6), or null. Only a Kick
* newer than [addedAtMs] — when this membership began — counts: an older one judged an earlier
* membership that a re-join has already put behind us. The owner is never kicked.
*/
fun honoredKickAgainst(
coalesced: Map<HexKey, GuestbookEntry>,
me: HexKey,
owner: HexKey,
addedAtMs: Long,
): GuestbookEntry? {
val self = me.lowercase()
if (self == owner.lowercase()) return null
return coalesced[self]?.takeIf { it.action == GuestbookAction.KICK && it.ms > addedAtMs }
}
/**
* The CORD-02 §5 coalesce of already-opened [entries] (latest motion per npub, Kicks honored
* against [authority]) down to the JOINed member set.
@@ -455,12 +455,7 @@ class ConcordCommunitySession(
* A Kick older than the entry's `added_at` judged an earlier membership — a re-join (a later
* Join, or a re-invite that re-added the entry) leaves it behind. Never for the owner.
*/
fun kickedMe(): GuestbookEntry? {
val me = myPubKey.lowercase()
if (me == entry.owner.lowercase()) return null
val mine = _guestbook.value[me] ?: return null
return mine.takeIf { it.action == GuestbookAction.KICK && it.ms > entry.addedAt }
}
fun kickedMe(): GuestbookEntry? = ConcordActions.honoredKickAgainst(_guestbook.value, myPubKey, entry.owner, entry.addedAt)
/** The size of [allMembers] — the community's true (best-effort) member count. */
fun memberCount(): Int = allMembers().size
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
@@ -185,4 +186,22 @@ class ConcordKickTest {
val byOwner = ConcordActions.buildGuestbookKick(owner, gb, target.pubKey, citation = null, createdAt = 10L)
assertNotNull(session(c, target.pubKey, w.controlWraps + join + byOwner).kickedMe())
}
/** The shared rule both the app session and amy apply (CORD-04 §6). */
@Test
fun honoredKickAgainstOnlyCountsAKickNewerThanTheMembership() {
val me = "a".repeat(64)
val owner = "b".repeat(64)
val kick = GuestbookEntry(member = me, action = GuestbookAction.KICK, createdAt = 100, inviteCreator = null, inviteLabel = null, author = owner)
val coalesced = mapOf(me to kick)
assertNotNull(ConcordActions.honoredKickAgainst(coalesced, me.uppercase(), owner, addedAtMs = 0))
assertNotNull(ConcordActions.honoredKickAgainst(coalesced, me, owner, addedAtMs = 99_999))
// Re-joined after the Kick: it judged the earlier membership.
assertNull(ConcordActions.honoredKickAgainst(coalesced, me, owner, addedAtMs = 100_000))
// The owner is never kicked; a latest motion that is a Join is not a Kick.
assertNull(ConcordActions.honoredKickAgainst(mapOf(owner to kick), owner, owner, addedAtMs = 0))
val join = GuestbookEntry(member = me, action = GuestbookAction.JOIN, createdAt = 200, inviteCreator = null, inviteLabel = null)
assertNull(ConcordActions.honoredKickAgainst(mapOf(me to join), me, owner, addedAtMs = 0))
}
}