test(quartz): pin what a soft-banned Concord staffer can still do

CORD-04 §4 row 3 of docs/concord-banlist-rank-conformance.md was left open as
"a genuine fixpoint-ordering question". This reproduces what that gap costs.

ConcordCommunityState.fold gates METADATA/CHANNEL/INVITE through
authority.hasPermission (`!isBanned && ..`), but ROLE, GRANT and BANLIST are
gated inside AuthorityResolver.resolve by holdsManageRoles / bitsOf /
effectivePermissionsOf, none of which consult the banlist — and none of which
can, as written, since the roles/grants fixpoint settles before `banned` is
computed. So half the Control Plane honors a ban and half is blind to it.

A banned member who still holds control_root therefore keeps the roster: they
revoke the surviving moderators, retire the roles beneath them, ban everyone
they outrank, and — since a role edition they author is honored — mint a fresh,
unbanned npub at the next position down. That npub passes every ban-aware gate,
so it tombstones the channels (terminal ids), rewrites the metadata, and, being
a non-banned BAN holder, is accepted as a rotator by drainConcordRekeys.

The tests assert the CURRENT, VULNERABLE behaviour so it cannot regress
silently; each ESCALATION assertion is to be inverted, not deleted, when the
ordering rule lands. Two companions pin what the fix must preserve: self-unban
and puppet-unban both stay refused, closed already by the delta rank rule.

Also records why a chain-local fix is insufficient — forking the banlist at
genesis dodges any "was the author banned by this edition's parent" rule, and
§4's re-heal union carries the rogue bans in anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
This commit is contained in:
Claude
2026-08-08 21:02:37 +00:00
parent 4f41f16db5
commit f2160f6264
@@ -0,0 +1,276 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* What a **soft-banned staffer** can still do to a community — the reproduction behind
* `docs/concord-banlist-rank-conformance.md` §4 row 3, which the report left open as "a genuine
* fixpoint-ordering question, not a plain oversight".
*
* The asymmetry these tests pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL / INVITE
* through `authority.hasPermission`, which is `!isBanned && …`, but ROLE, GRANT and BANLIST are
* gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` /
* `effectivePermissionsOf` — none of which consult the banlist. Nor could they as written: the
* roles/grants fixpoint runs before `banned` is computed at all. So half the Control Plane honors
* a ban and half is structurally blind to it, and a banned member who still holds `control_root`
* keeps full authority over the roster.
*
* **These tests assert the CURRENT, VULNERABLE behaviour**, so the escalation cannot regress
* silently or be "fixed" by accident without someone noticing. Every `ESCALATION:` assertion here
* must be INVERTED — not deleted — when the ordering rule lands. [selfUnbanIsStillRefused] and
* [aJuniorPuppetCannotLiftASeniorsBan] are the opposite: they pin behaviour the fix must preserve.
*
* Note for whoever writes that fix: a chain-local rule ("the author must not be banned by the state
* their edition chains from") is NOT sufficient — see
* [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan]. The rule has to bind
* CORD-04 §4's re-heal union too.
*/
class BannedStaffEscalationTest {
private val owner = "0f".repeat(32)
private val alice = "a1".repeat(32) // Admin, position 1 — the member who gets banned
private val bob = "b2".repeat(32) // Mod, position 5
private val carol = "c3".repeat(32) // plain member, no role
private val puppet = "e5".repeat(32) // a fresh npub alice controls
private val adminRole = "11".repeat(32)
private val modRole = "22".repeat(32)
private val puppetRole = "33".repeat(32)
private val banlistEntity = "44".repeat(32)
private val channelEntity = "55".repeat(32)
private val metadataEntity = "66".repeat(32)
private val bobGrantEntity = "32".repeat(32)
private val puppetGrantEntity = "35".repeat(32)
// MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64
private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}"""
private val modJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN
private fun edition(
kind: ControlEntityKind,
entity: String,
version: Long,
prev: ByteArray?,
content: String,
author: String,
rumorId: String,
) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0)
private fun role(
id: String,
json: String,
author: String = owner,
version: Long = 0,
prev: ByteArray? = null,
) = edition(ControlEntityKind.ROLE, id, version, prev, json, author, "role-$id-$version-$author")
private fun grant(
coordinate: String,
member: String,
roleIds: List<String>,
author: String,
version: Long = 0,
prev: ByteArray? = null,
) = edition(
ControlEntityKind.GRANT,
coordinate,
version,
prev,
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
author,
"grant-$coordinate-$version-$author",
)
private fun banlist(
author: String,
version: Long,
prev: ByteArray?,
vararg banned: String,
) = edition(
ControlEntityKind.BANLIST,
banlistEntity,
version,
prev,
"[${banned.joinToString(",") { "\"$it\"" }}]",
author,
"ban-$version-$author",
)
private fun channel(
json: String,
author: String,
version: Long,
prev: ByteArray?,
) = edition(ControlEntityKind.CHANNEL, channelEntity, version, prev, json, author, "chan-$version-$author")
private fun metadata(
json: String,
author: String,
version: Long,
prev: ByteArray?,
) = edition(ControlEntityKind.METADATA, metadataEntity, version, prev, json, author, "meta-$version-$author")
private val channelV0 = channel("""{"name":"general"}""", owner, 0, null)
private val metadataV0 = metadata("""{"name":"My Community"}""", owner, 0, null)
private val bobGrantV0 = grant(bobGrantEntity, bob, listOf(modRole), owner)
private val modRoleV0 = role(modRole, modJson)
/** The owner-authored community every test starts from: two roles, two grants, a channel, metadata. */
private fun community() =
mutableListOf(
role(adminRole, adminJson),
modRoleV0,
grant("31".repeat(32), alice, listOf(adminRole), owner),
bobGrantV0,
channelV0,
metadataV0,
)
/** The owner bans alice. Genesis of the banlist, so every test can fork or chain off it. */
private val ownerBansAlice = banlist(owner, 0, null, alice)
/** Alice, already banned, mints a role just below herself and hands it to a fresh npub. */
private fun aliceMintsAPuppet() =
listOf(
role(puppetRole, """{"name":"Puppet","position":2,"permissions":"95"}""", author = alice),
grant(puppetGrantEntity, puppet, listOf(puppetRole), author = alice),
)
@Test
fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() {
val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner)
assertTrue(r.isBanned(alice), "the owner's ban lands")
assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her")
// ...but this is the one every ROLE/GRANT/BANLIST gate inside resolve() actually consults.
assertTrue(
r.effectivePermissions(alice).has(ConcordPermissions.MANAGE_ROLES),
"ESCALATION: a banned staffer keeps the permissions the resolver's own gates read",
)
}
@Test
fun aBannedAdminPromotesAFreshSockpuppetToAdmin() {
val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
assertEquals(2, r.rank(puppet), "ESCALATION: the banned admin's role edition is honored")
assertFalse(r.isBanned(puppet), "the puppet is a clean npub — nothing to filter it on")
assertTrue(
r.hasPermission(puppet, ConcordPermissions.MANAGE_CHANNELS),
"ESCALATION: a banned member minted a live admin with the ban-aware check passing",
)
}
@Test
fun theSockpuppetDeletesEveryChannelAndRewritesTheMetadata() {
val editions =
community() + ownerBansAlice + aliceMintsAPuppet() +
// A channel tombstone is terminal — CORD-03: the id is never reused.
channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) +
metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash)
val state = ConcordCommunityState.fold(editions, owner)
assertEquals(0, state.channels.size, "ESCALATION: the community's channels are irrecoverably tombstoned")
assertEquals("Owned by the guy you banned", state.metadata?.name, "ESCALATION: and its identity rewritten")
}
@Test
fun theSockpuppetBansEveryMemberBeneathIt() {
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
assertTrue(r.isBanned(bob), "ESCALATION: the surviving moderator is silenced, losing all authority with it")
assertTrue(r.isBanned(carol), "ESCALATION: and the plain members with them")
}
@Test
fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() {
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
assertTrue(r.isBanned(bob), "ESCALATION: banGate reads effectivePermissionsOf, which ignores her own ban")
assertTrue(r.isBanned(carol), "ESCALATION: same")
}
@Test
fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() {
// The same attack as above, except her edition does NOT chain onto the edition that banned
// her — it forks at genesis. So a rule that only asks "was the author banned by this
// edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in
// regardless. Any fix has to bind the union, not just the chain.
val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only")
assertTrue(r.isBanned(bob), "ESCALATION: and so does the banned admin's, healed in as a concurrent ban")
assertTrue(r.isBanned(carol), "ESCALATION: same")
}
@Test
fun aBannedAdminRevokesTheSurvivingModerators() {
val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash)
val r = AuthorityResolver.resolve(editions, owner)
assertEquals(null, r.rank(bob), "ESCALATION: a banned admin stripped a live moderator's roles")
assertFalse(r.hasPermission(bob, ConcordPermissions.BAN), "ESCALATION: leaving nobody but the owner able to act")
}
@Test
fun aBannedAdminDeletesEveryRoleBeneathThem() {
val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash)
val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner)
assertEquals(null, r.roles()[modRole], "ESCALATION: a banned admin retired a role beneath them")
assertEquals(null, r.rank(bob), "ESCALATION: every holder of it silently loses their standing")
}
@Test
fun selfUnbanIsStillRefused() {
// docs/concord-banlist-rank-conformance.md §4 row 3, the half that IS closed: the delta rule
// gates removals too, and strict outranking means nobody outranks themselves.
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
}
@Test
fun aJuniorPuppetCannotLiftASeniorsBan() {
// The puppet sits at position 2 and alice at 1, and no edition may claim a position at or
// above its own signer — so her delegation chain can only ever descend. Nothing she mints
// can outrank her, and so nothing she mints can unban her.
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
}
}