feat(cli): add amy concord refound + rekey — the rotation half of CORD-06

`refound` is the hard removal a ban cannot give: a ban only strips standing,
while the removed member keeps every key they ever held. Rotating the
`community_root` — and, since CORD-02 §2, a fresh `control_root` beside it so
a demoted staffer's retained secret dies with the epoch — is what actually
closes the room. The compacted Control Plane is re-sealed at the new epoch
and each retained member gets a rekey blob.

Authority mirrors Amethyst exactly: `hasPermission`, never
`effectivePermissions`, so a banned BAN-holder cannot launch one; the owner
is never a valid target; and removal takes the same rank rule as a ban
(CORD-04 §3) — an admin cannot Refound a peer admin out.

The recipient set reaches past the roster to the Guestbook AND the authors of
every channel message we can decrypt, because a member who only ever posted
holds no role and files no Guestbook motion — building the set without them
silently expels them. It is still a floor, not a census.

`rekey` is the receive half, and without it `refound` was actively harmful
from the CLI: a retained member's blob sat on the relay unopened, so a
Refounding launched from amy stranded every other amy member. It authorizes
the rotator against the roster of the epoch being LEFT and fails closed.

Verified end to end against a loopback geode — the full cycle, which was not
previously expressible from the CLI at all:

    alice refound --remove <stranger>  → epoch 0 → 1, recipients=2
    (bob is kept because he POSTED, holding no role — the author harvest)
    bob rekey                          → epoch 0 → 1, same root + control_pk
    bob sends, alice reads it at the new epoch
    alice roles                        → the removed member is banned

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-09 18:48:28 -04:00
co-authored by Claude Opus 5
parent 4022a6a5da
commit 0aa3adfc07
2 changed files with 241 additions and 1 deletions
@@ -63,6 +63,8 @@ object ConcordCommands {
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
| concord join URL redeem an invite link and save the community
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
| open our blob and adopt the new epoch
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
| follow a Refounding we were left out of
| (CORD-06); refuses if that epoch banned us
@@ -71,6 +73,9 @@ object ConcordCommands {
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
| concord ban COMMUNITY USER ban a member
| concord unban COMMUNITY USER unban a member
| concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the
| control_root) so removed members lose every
| key — the hard removal a ban cannot give
""".trimMargin()
suspend fun dispatch(
@@ -80,7 +85,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|join|recover|roles|role|grant|ban|unban>",
"concord <create|list|import|channels|send|read|invite|join|recover|rekey|roles|role|grant|ban|unban|refound>",
help = USAGE,
routes =
mapOf(
@@ -93,11 +98,13 @@ object ConcordCommands {
"invite" to { rest -> invite(dataDir, rest) },
"join" to { rest -> join(dataDir, rest) },
"recover" to { rest -> recover(dataDir, rest) },
"rekey" to { rest -> rekey(dataDir, rest) },
"roles" to { rest -> ConcordModCommands.roles(dataDir, rest) },
"role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) },
"grant" to { rest -> ConcordModCommands.grant(dataDir, rest) },
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
"refound" to { rest -> ConcordModCommands.refound(dataDir, rest) },
),
)
@@ -486,6 +493,68 @@ object ConcordCommands {
}
}
/**
* `concord rekey [COMMUNITY]` — follow a Refounding we WERE re-keyed for (CORD-06).
*
* The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next
* epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its
* revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI
* strands every other CLI member even though their blob is sitting on the relay.
*
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed:
* a plane that will not fold yields no verdict and the community is skipped.
*/
private suspend fun rekey(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positionalOrNull(0)
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) {
val relays = relaysFor(ctx, sc)
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val received =
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
if (received == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
continue
}
if (received.newEpoch <= sc.rootEpoch) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
continue
}
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val editions = ConcordActions.controlEditions(controlWraps, cp)
if (editions.isEmpty()) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
continue
}
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
continue
}
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
store.upsert(storedFrom(sc, adopted))
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
}
Output.emit(mapOf("communities" to results))
return 0
}
}
fun notFound(handle: String): Int {
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
return 1
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
@@ -206,6 +207,176 @@ object ConcordModCommands {
operator fun component2() = editions
}
/**
* `concord refound COMMUNITY --remove USER[,USER…]` — a CORD-06 Refounding: the hard removal.
*
* A ban only strips standing; the removed member keeps every key they ever held, so the room is
* only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh
* `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The
* compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey
* blob; nobody else can follow.
*
* Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned
* BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same
* rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out.
*
* **The recipient set is a floor, not a census.** It is the roster Guestbook the authors of
* every channel message we can decrypt ourselves, minus the removed and already-banned — the
* same union Amethyst builds, because a member who only ever posted holds no role and leaves no
* Guestbook motion, and omitting them silently expels them. A member with no trace at all still
* cannot be re-keyed; `concord recover` is how they get back.
*/
suspend fun refound(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound <community> --remove USER[,USER…]").let { 2 }
args.rejectUnknown()
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val removed =
removeArg
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { ctx.requireUserHex(it).lowercase() }
.toSet()
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
val loaded = load(ctx, sc, dataDir)
val (cp, editions) = loaded
val state = ConcordCommunityState.fold(editions, sc.owner)
val authority = state.authority
val me = ctx.signer.pubKey
if (!ConcordReceive.isAuthorizedRotator(authority, me)) {
return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)")
}
if (removed.any { authority.isOwner(it) }) {
return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)")
}
// An admin cannot Refound a peer admin out any more than they could ban one.
if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) {
return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)")
}
// A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a
// split epoch it takes the current control_root (CORD-02 §2).
writeGuard(cp)?.let { return it }
val relays = ConcordCommands.relaysFor(ctx, sc)
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
var chain = editions
for (target in removed) {
val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner)
ctx.publish(banWrap, relays)
chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp))
}
// 2. Everyone we are keeping. See the note above on why this reaches past the roster.
val recipients =
(rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removed)
removeAll(authority.bannedMembers().map { it.lowercase() }.toSet())
}.toList()
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
val newRoot = RandomInstance.bytes(32)
val newControlRoot = RandomInstance.bytes(32)
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val build =
ConcordActions.buildRefounding(
rotatorSigner = ctx.signer,
communityId = sc.communityId,
priorRoot = sc.root.hexToByteArray(),
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = sc.rootEpoch,
priorControlWraps = controlWraps,
priorControlKeys = cp,
recipientsXOnly = recipients,
staffXOnly = authority.staffMembers(),
createdAt = TimeUtils.now(),
ownerPubKey = sc.owner,
)
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it).
build.controlWraps.forEach { ctx.publish(it, relays) }
build.rekeyWraps.forEach { ctx.publish(it, relays) }
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
// epoch we are leaving for the anti-rollback floor.
val adopted =
ConcordReceive.withAdoptedRoot(
ConcordCommands.entryFor(loaded.community),
newRoot,
build.newEpoch,
build.newControlKeys.address.hexToByteArray(),
newControlRoot,
)
ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted))
Output.emit(
mapOf(
"community_id" to sc.communityId,
"removed" to removed.toList(),
"from_epoch" to sc.rootEpoch,
"root_epoch" to build.newEpoch,
"recipients" to recipients.size,
"control_wraps" to build.controlWraps.size,
"rekey_wraps" to build.rekeyWraps.size,
),
)
return 0
}
}
/** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */
private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set<String> = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
/** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */
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())
/**
* Authors of every channel message we can decrypt. Most members never send a Guestbook motion,
* so without this a Refounding silently expels everyone who had only ever posted.
*/
private suspend fun channelAuthorsOf(
ctx: Context,
sc: StoredCommunity,
state: ConcordCommunityState,
): Set<String> {
val out = HashSet<String>()
val relays = ConcordCommands.relaysFor(ctx, sc)
for ((channelIdHex, _) in state.channels) {
runCatching {
val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(key.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() }
}
}
return out
}
/** Drain the control plane and return its keys + current editions to chain onto. */
private suspend fun load(
ctx: Context,