Files
amethyst/docs/concord-banlist-rank-conformance.md
T
Claude 6147f72c81 docs(concord): check the audit against Armada, and correct two conclusions
Read gitlab.com/soapbox-pub/armada src/concord-v2/ against every finding. Two
conclusions change.

B2 is NOT consensus-affecting, and the warning in the last commit was wrong.
Armada's foldControlState already runs the same bounded two-pass — fold once,
take the banlist, re-fold with banned authors' editions excluded — arrived at
independently, same shape, same CORD-04 §4 justification in the comment. This
change brings us into line rather than out of it. One narrower divergence
remains: they keep pass 1's banlist as final, we recompute it in pass 2, so a
banned admin's mass-ban still stands for them and is dropped by us. Both
defensible; ours closes an attack theirs leaves open, and the self-erasure they
guard against is unreachable under the rank rule.

A2's fork is resolved, in favour of the fix having been necessary.
useLinkRefreshWatch2 re-posts every invite bundle on each epoch change, so the
"if anything re-mints at a stable coordinate" branch is what actually happens —
in any cross-client community a removed member's Amethyst client would have
pulled the new root within fifteen minutes. Their catch-up is push instead:
a privileged member sends a direct invite carrying the fresher root, so a human
authorizes each re-admission, and useBanSelfRemove2 has a banned member's own
client silently drop the community. The liveness half stands and now has two
concrete options rather than an open question.

Also recorded: B1 is present in Armada unfixed, in exactly the same place
(bootstrapHead is unbounded, headCandidates uses it, pickHead raises the floor) —
the second bug both clients share by reading one section the same way, so it goes
to them in writing like the rank rule did. A1 was ours alone; they gate invite
creation on CREATE_INVITE in both the hook and the page. C1 is unchanged on their
side. A4 is a shared gap. And a divergence in the other direction: their banlist
takes only the head's content, with no §4 re-heal union, so we honor concurrent
bans they drop.

B4 is marked unchecked rather than guessed at — I could not locate their
recipient-set construction with confidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
2026-08-09 15:54:55 +00:00

15 KiB

Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance)

Status: conformance bug. Reproduced in Amethyst and fixed there (see §6); present by inspection in Armada. Finding #3, left open in §4 as a fixpoint-ordering question, is now also implemented in Amethyst — see the 2026-08-09 update before §Rollout status. Severity: privilege escalation. Any BAN holder can neutralise every authority above them, including the owner. Reported by: Amethyst (MIT), 2026-07-20. Findings verified by unit test; see "Evidence" below.


1. Summary

CORD-04 §3 requires that, for every action, the actor both hold the required permission bit and strictly outrank its target. It names banning as the worked example. But CORD-04 §4 — the section that actually defines the Banlist — states only the bit half of the rule:

The Banlist is the one anti-roster: a signed list of npubs, honored only if its signer holds BAN.

Both known implementations implement §4 as written and omit §3's rank half at the Banlist write path. The result is that a low-ranked moderator can ban the admins above them, and the owner.

This is not a spec gap. §3 is unambiguous and normative. It is a conformance bug that both clients share, and it is almost certainly caused by §4 being readable in isolation.

2. What the spec requires

CORD-04 §3 (emphasis in original):

position orders authority, lower is higher: the owner is position 0 (never a Role), a roleless member is effectively last, and a member's rank is the lowest position among their Roles. One hard rule binds every action: the actor must hold the required bit and strictly outrank its target — equal cannot act on equal (an admin cannot ban a peer admin) — and no edition may claim a position at or above its own signer, so nobody can promote themselves toward the top.

Two things stand out: the rule binds every action, and the parenthetical example it chooses is exactly a ban — "an admin cannot ban a peer admin."

Restated as step 3 of the normative authorisation algorithm, CORD-04 §5:

To honor an action, a reader:

  1. Verifies the seal, learning the actor's real npub.
  2. Folds the Roster and resolves that npub's effective permissions and position.
  3. Confirms the actor holds the action's required bit and strictly outranks its target, traced to the owner.

And on the owner, CORD-04 §2:

The Roster is owner-rooted: … the chain terminates at the owner, who is proven by the community_id itself (CORD-02), occupies position 0, and is supreme and unremovable.

The Grant path states the same rule inline, which is presumably why it got implemented there:

A Grant maps a member's npub to their Roles, honored only if its signer outranks every Role it hands out (§3).

CORD-02 §5 likewise restates it for Kicks: "a Kick honored only if its signer holds KICK and outranks the target, CORD-04".

§4 is the only permissioned entity whose section does not restate the rank half. That asymmetry looks like the proximate cause of the bug in both codebases.

3. What the implementations do

Armada (src/concord-v2/lib/control.ts)

const banlistGate = (p: ParsedEdition): boolean => {
  if (!isAuthorized(roster, p.author, ownerHex, Permissions.BAN)) return false;
  if (!citationOk(p)) return false;
  try {
    return Array.isArray(JSON.parse(p.content));
  } catch {
    return false;
  }
};

with (src/concord-v2/lib/roles.ts):

export function isAuthorized(
  roles: CommunityRoles, actorHex: string, ownerHex: string | undefined, permission: bigint,
): boolean {
  if (ownerHex === actorHex) return true;
  return hasPermission(roles, actorHex, permission);
}

isAuthorized is a pure bit lookup. Notably Armada already has the correct primitive and uses it on the role path:

export function canActOnPosition(
  roles: CommunityRoles, actorHex: string, ownerHex: string | undefined,
  targetPosition: number, permission: bigint,
): boolean {
  if (ownerHex === actorHex) return true;
  return hasPermission(roles, actorHex, permission) &&
    outranks(roles, actorHex, ownerHex, targetPosition);
}

So the fix is largely a matter of routing the Banlist gate through the primitive that already exists — though see §5, because a whole-list entity needs a per-entry rule.

Amethyst (quartz/…/concord/cord04Roles/AuthorityResolver.kt)

fun banGate(e: ControlEdition): Boolean =
    e.author.lowercase() == ownerLower ||
        effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN)

and the parallel gate in ConcordCommunityState.authorizedHeads:

EditionFold.foldGated(list, floors) {
    authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit))
}

Same shape: bit only, no rank. Amethyst also has a correct canActOn(actor, target, bit) that implements §3 faithfully, and uses it for Grants — and, since this report, for deciding what the UI will author. Neither client rank-checks the contents of a Banlist edition.

4. Consequences

All of the following were reproduced against Amethyst's fold with unit tests. Community: owner, alice = Admin at position 1 (holds BAN), bob = Mod at position 5 (holds BAN).

# Scenario Result Spec violated
1 bob (pos 5) bans alice (pos 1) ban is honored §3 "strictly outrank its target"
2 bob bans the owner ban is honored §2 "supreme and unremovable"
3 A banned member holding BAN unbans themselves honored §4 "drops every event from a banned npub … or authority action"
4 A forked ban survives an unban that does not chain onto it stays banned — (interacts with §4 re-heal; see below)

Why #1 is escalation, not a cosmetic ordering issue. Once banned, a member loses all authority: effective-permission checks are !isBanned && … in both clients, and §4 mandates that honest clients drop every event from a banned npub. So a single edition from the most junior BAN holder permanently silences every admin above them. In a community whose owner is inactive — the common case CORD-06 Refounding exists to address — there is no one left who can undo it.

#2 is the sharpest violation. The spec goes out of its way to protect position 0, including the clause "no Role may ever claim position 0, or an owner could create a peer nobody outranks." A Banlist entry bypasses all of that, because the Banlist is just a list of keys and nothing checks what is in it.

#3 cuts the other way and is worth deciding deliberately. Because the gate reads role-derived permissions rather than post-ban ones, a banned moderator can lift their own ban. So bans do not stick against anyone holding BAN. Note this one is a genuine fixpoint-ordering question, not a plain oversight: you cannot know who is banned until you fold the Banlist, and you cannot decide who may fold into the Banlist without knowing who is banned. The spec should say which order wins.

#4 is a pre-existing interaction, not necessarily a bug. §4's convergence design ("re-heal … guarantees convergence to the union") means a ban that forks off the head is unioned in and is not cleared by an unban that chains elsewhere. That is correct for legitimate concurrent bans. It becomes a weapon once #1 holds, because a rogue's ban is both unauthorised and sticky.

5. Proposed fix

The difficulty is that the Banlist is a single whole-list document ("replaced entire on every edit"), while §3's rule is stated per target. A gate can only be applied to a whole edition, so the rule needs a per-entry formulation. We suggest specifying it as a delta rule:

An edition of the Banlist is evaluated against the state its prev denotes. Let delta be the symmetric difference between the edition's list and that state's list — the npubs it adds and the npubs it removes. For each npub in delta, the signer MUST hold BAN and strictly outrank that npub, evaluated against the Roster as of that edition. The owner MUST NOT appear in the Banlist; an edition adding them is invalid. Entries in delta the signer may not act on are ignored; the remainder of the edition applies.

Rationale for each clause:

  • Delta, not whole list. Otherwise every edition would have to re-justify every standing ban, and a moderator could never edit a list containing someone senior — including to add a peon.
  • Removals are gated too. Unbanning is an authority action on the target; without this, the rank rule is trivially bypassed (ban is symmetric with unban here).
  • Ignore, don't reject. Rejecting the whole edition would make one bad entry discard a legitimate bulk-ban — which §4 explicitly recommends as the collision remedy — and would let a rogue grief the list by forcing rejections. Ignoring converges and composes with re-heal.
  • "As of that edition." Ranks change. The spec should state that a Banlist edition's authority is judged against the Roster the fold has settled behind it, which is what §5's "traced to the owner" already implies for the Roster and what both clients already do for Grants.
  • Owner excluded explicitly. Today unbannability is only derivable from position 0 plus strict outranking. Since the Banlist bypasses rank entirely, an explicit prohibition is worth one sentence.

We'd also suggest §4 restating the rank half inline, the way §2 does for Grants and CORD-02 §5 does for Kicks. Both independent implementations read §4 in isolation and both got it wrong the same way; that is strong evidence the section is the problem, not the readers.

Update, 2026-08-09: we have now implemented #3

Amethyst now answers the ordering question rather than leaving it open, because #3 turned out to be the doorway to a full community takeover and not merely an inconsistency — a banned staffer who kept control_root kept the entire roster, and could mint a fresh un-banned npub that passed every ban-aware gate. The write-up is docs/concord-soft-ban-audit.md (B2).

The rule we shipped: authority only ever shrinks, over two passes. Pass A resolves exactly as before and yields a candidate banlist; pass B re-resolves with every author on that list treated as holding no authority at all, for roles, grants and the Banlist alike. Two passes, always, so it terminates by construction. It cannot oscillate on mutual bans either, because the rank rule makes them unreachable: only a member who strictly outranks you may ban you, and you cannot outrank them back.

Note what it costs, because it is not obvious and you would hit it too: this cascades. Every edition a banned member ever authored is dropped, grants included, so banning an admin also demotes everyone that admin promoted. We think that is the literal reading of §4 and it is what kills the sockpuppet — but a legitimate promotion by a later-banned admin vanishes with it, and the owner has to re-issue it. If you read §4 as scoping only to editions authored after the ban, say so; that is implementable too, but it needs the spec to define an ordering between an edition and a Banlist entry, which today it does not.

We checked your implementation before writing this, and you got there first: foldControlState already runs the same two-pass, with the same §4 justification in the comment. So this is us catching up, not diverging — with one narrower difference. You keep pass 1's Banlist as the final word; we recompute it in pass 2. So a banned admin's mass-ban of everyone beneath them still stands for you and is dropped by us. Your stated reason is to stop the anti-roster erasing itself; ours is that an edition should not outlive its author's removal, and the self-erasure case is unreachable under the rank rule anyway, since only a member who strictly outranks you can ban you. We would rather converge than be right — tell us which way and we will move.

Two more things that fell out of reading src/concord-v2/ side by side, both worth their own look:

  • bootstrapHead has no bound (lib/version.ts), and headCandidates uses it for the compaction arm while pickHead then raises the stored floor to whatever won. One authorized edition at version = 2^63 - 1 therefore becomes an entity's permanent head: the floor rises to match, nothing honest can exceed it, and even a Refounding that drops the edition falls back to the remembered head — which is that edition. It needs no ban and no sockpuppet, just one ordinary permission bit. This is the second bug both implementations share by reading the same section the same way; ours is described in docs/concord-soft-ban-audit.md (B1), and we bounded the jump the arm will follow.
  • Your Banlist takes only the gated head's content, with no §4 re-heal union. We union in every authorized non-ancestor edition, which is what defeats an attempt to launder a ban away by forking the list at genesis. So we honor concurrent bans you drop. Which is normative?

A chain-local rule is not enough, and this is the trap worth flagging: "the author must not be banned by the state their edition chains from" is bypassed by forking the Banlist at genesis, where no parent ever mentions the ban and §4's re-heal union carries it in anyway. The rule has to bind the union, which is why ours is a whole-pass mask rather than a per-edition check.

This widens the divergence in §6: we now drop editions you honor in any community where a privileged member was banned, not only where a signer failed to outrank their target.


Separately, please rule on #3: whether a banned npub's Banlist edition is honored. Our reading of §4 ("drops every event from a banned npub — message, reaction, edit, or authority action") is that it must not be, but the fixpoint ordering needs to be stated for that to be implementable consistently.

6. Rollout status

Amethyst enforces the rule described in §5 as of this report — both on the fold (AuthorityResolver) and on what it will author (UI + the ban/unban write path, which now reads the honored banlist so an unauthorized entry can never be laundered into a list we sign).

We're flagging the consequence plainly: this is consensus-affecting. Until Armada ships the same rule, the two clients can show different banlists — Amethyst will ignore a ban that Armada honors whenever the signer did not outrank the target. We judged shipping the spec-conformant behaviour better than continuing to honor an escalation, but we recognise that is a decision with a cost for your users as well as ours, and we're happy to discuss timing, or to adjust if you read §3/§5 differently than we do.

If it is useful, our implementation is MIT and the delta rule is about 40 lines in AuthorityResolver.resolve; you're welcome to lift the approach outright.

7. Evidence

Reproductions live in Amethyst's AuthorityResolverTest (quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/). Each failed before the change and passes after it:

  • aBanHolderCannotBanAMemberItDoesNotOutrank
  • aBanHolderCannotBanTheOwner
  • anUnrankedBanIsDroppedWithoutOrphaningTheRestOfTheList (pins the "ignore, don't reject" semantics of §5)

Two companions pin the behaviour the fix had to preserve, and passed throughout:

  • aBanHolderStillBansThoseItOutranks
  • theOwnerBansAnyone

Happy to port these to Armada's suite or restate them as spec examples if that is useful.