From 0ed87cd71429c6b6204d3a07fc7ce89190933775 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 20:01:01 -0400 Subject: [PATCH] fix(cli): amy complies with a Kick against its account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cli/commands/ConcordChannelCommands.kt | 3 ++ .../amethyst/cli/commands/ConcordCommands.kt | 43 +++++++++++++++++++ .../cli/commands/ConcordModCommands.kt | 9 +--- .../amethyst/cli/stores/ConcordStore.kt | 7 +++ .../commons/actions/ConcordActions.kt | 16 +++++++ .../model/concord/ConcordCommunitySession.kt | 7 +-- .../commons/model/concord/ConcordKickTest.kt | 19 ++++++++ 7 files changed, 90 insertions(+), 14 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 864509647d..1e0fedccc2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -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 = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 9b936c5ae9..459e1b4827 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -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 = + 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 8ad23dcd11..58de5fa3b3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -740,14 +740,7 @@ object ConcordModCommands { private suspend fun guestbookMembersOf( ctx: Context, sc: StoredCommunity, - ): Set = - 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 = 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, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index 225bf928e8..e1e743a966 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -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() 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 d458ba1c2e..f367a52626 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 @@ -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, + 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. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 8e0e16f65d..c1ac2b32de 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordKickTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordKickTest.kt index 033509101c..fe96a3980a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordKickTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordKickTest.kt @@ -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)) + } }