Files
amethyst/docs/concord-soft-ban-audit.md
T
Claude 9cc19c60ca fix(concord): three defects found auditing this branch's own changes
Self-review of the diff before merge. One of these is a real correctness bug in
the B2 fix as shipped.

The resolver stopped after two passes, which left the mask a pass resolved UNDER
disagreeing with the banlist that pass produced — and the disagreement is not
cosmetic. A moderator whose only ban came from an admin the owner banned
concurrently is released by pass 2, correctly; but pass 2 had already dropped
her editions, because she was on pass 1's list. The fold then reported her as a
moderator in good standing whose promotions had silently vanished, and did so
deterministically, so she never got them back. resolve() now iterates until the
mask and the resulting banlist agree.

The mask cannot simply be assumed to shrink, which is why this is bounded rather
than proven monotone: masking an author can strip a THIRD member's role, which
drops their rank to roleless, which lets a junior BAN holder who previously could
not reach them ban them after all. The loop keeps its last pass if it does not
settle within the cap — still better than the two-pass answer, and it always
terminates. Real communities settle on the first or second pass, and the
skip-if-no-banned-author guard means most never enter the loop at all.

boundRecipients could exceed its own budget while reporting that it had capped
at it, because the roster was added with filterTo before the budget loop ran.
The roster now goes in whole deliberately — it is owner-rooted and cannot be
padded from outside, and dropping an admin to make room for a stranger inverts
the point — and the log reports what was actually kept and dropped.

mintConcordInvite started requiring a session, which the owner's own invite
button would not have on a cold start, since sessions are built asynchronously
off the joined list. The owner is proven by the community id, so they are read
off the entry; everyone else still needs the folded roster.

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

35 KiB
Raw Blame History

Concord: soft-ban and Control Plane audit

Scope: what a removed member — or a moderator who turns — can still do to a Concord community. Date: 2026-08-09. Status: A1A4, B1, B2 and B4 are fixed on this branch; the rest are accepted, deferred to the spec, or belong to other people's relays. Each section carries its own status line. Companion: docs/concord-banlist-rank-conformance.md (the rank half of CORD-04 §4, already reported to Armada and fixed here).

Each finding says how it was established. Verified means a test in this repo reproduces it; Read means it follows from the code but no test was written. Every "Verified" line names the test.


How to read this list

Findings are split by what the attacker needs, because that decides who owns the fix and how urgent it is:

The distinction is not academic. Part A is where the realistic attacker is: an irritated user who just got banned has the app already installed and is not going to write a Nostr client. Part B is where the damage ceiling is. Fix Part A first because it is cheap and it is what will actually happen; fix Part B because it is what ends communities.

Two structural causes account for most of both halves:

  • Authority is checked in several places that disagree. ConcordCommunityState.fold gates METADATA/CHANNEL/INVITE through the ban-aware authority.hasPermission. AuthorityResolver gates ROLE/GRANT/BANLIST internally through holdsManageRoles / bitsOf / effectivePermissionsOf, which are ban-blind. The UI gates through effectivePermissions, also ban-blind. The action layer mostly does not gate at all. Same question, four answers.
  • A ban removes standing, never keys. community_root, channel keys, control_root if staff, and live invite links all survive it. Only a CORD-06 Refounding rotates those — which is why anything that makes Refounding expensive (B4) or reversible (A2) is worth more to an attacker than it first looks.

Summary

Part A — reachable from stock Amethyst (our bugs)

# Finding Severity Status
A1 Any member — banned included — mints a working invite in one tap Critical Fixed
A2 Stranded recovery runs on a timer and never checks the banlist Critical Fixed (security half; liveness half open)
A3 The action layer has no permission checks; the UI's are ban-blind High Fixed
A4 A banned member keeps broadcasting "typing", and we keep showing it Low Fixed
A5 A banned member's own client keeps reading and rendering everything Medium Inherent — product decision

Part B — requires a malicious client

# Finding Severity Needs a ban? Status
B1 One edition at version = Long.MAX_VALUE pins an entity forever Critical No — any bit-holder Fixed
B2 A banned staffer keeps Role/Grant/Banlist authority Critical Yes Fixed (matches Armada)
B3 A rogue rotator compacts the banlist away High Via B2 Mitigated by B2
B4 The Refounding recipient set is attacker-inflatable High No Fixed (bounded)
B5 The ban is per-pubkey; the channel key is not revoked High Yes Inherent — Refounding is the answer
B6 Channel history is deletable on a naive third-party relay High Yes Correct here; external relays at risk
B7 The base-rekey plane is writable by every member Low Yes Accepted

Part C — interop and not-yet-shipped

# Finding Severity Status
C1 Banlist rank rule diverges from Armada Medium Reported; B2 widens the divergence
C2 CORD-07 voice rooms are key-gated, not roster-gated Design Note for whoever ships voice

Part A — reachable from stock Amethyst

No custom tooling. A banned user with the shipping app, or our own background sweep.

A1 — Any member, banned included, mints a working invite in one tap

Status: fixed. mintConcordInvite and its button now require CREATE_INVITE (or ownership).

Critical. The single most likely thing an irritated banned user actually does. Read: AccountConcordActions.mintConcordInvite, ConcordChannelListScreen (the PersonAdd IconButton).

mintConcordInvite checks exactly two things: that the account is writeable, and that we have the community in our joined list. No CREATE_INVITE check. No banlist check. And unlike the Edit and channel-management buttons beside it, the invite IconButton is rendered with no canEdit guard at all — it is always there, for everyone.

So the flow is: get banned, stay in the app, tap the person-add icon, share the link. The minted bundle carries the community root we still hold, so anyone who opens it joins for real. Every invited account is a fresh unbanned npub that moderators then have to ban one at a time.

Two aggravating details. The mint publishes a fresh link signer per invite, so it is a brand-new coordinate — revoking the links the banned member was given does not touch the ones they mint. And CREATE_INVITE is a real permission bit that the fold enforces on INVITE_* Control entities, but the actual invite mechanism is a standalone kind-33301 addressable event published outside the Control Plane, so that gate never applies to it. The permission is, in practice, unenforced.

Fix. Gate mintConcordInvite on hasPermission(me, CREATE_INVITE) || isOwner(me), and gate the button on the same. This is contained, uncontroversial, and closes the realistic attack. Do it first.

A2 — Stranded recovery runs on a timer and never checks the banlist

Status: security half fixed; liveness half open — and the fork is now resolved. isStranded / mergeForward take bannedAtCurrentEpoch as a required argument, so a removed member is no longer walked back in. The open question was whether anything re-mints at a stable coordinate. Armada doesuseLinkRefreshWatch2 re-posts every bundle on each epoch change — so in any cross-client community this was a live removal bypass, not a hypothetical, and the fix was load-bearing. The liveness half stands: Amethyst re-mints nothing, so legitimate recovery never fires for an Amethyst-only community. See the Armada comparison for the two ways out.

Critical, and it forks. Read: ConcordStrandedRecovery, AccountConcordActions.recoverStrandedConcordCommunities, AccountConcordActions.mintConcordInvite.

This is in Part A because our own client performs it, unprompted: the recovery sweep runs on the revision tick for every joined community holding an inviteRef, every 15 minutes. The banned user does nothing but leave the app installed.

ConcordStrandedRecovery.isStranded / mergeForward take only (entry, bundle) — no banlist check, no check that we were legitimately re-keyed. The whole test is "the bundle at my stored inviteRef sits at a higher epoch than I do", and the unlock token lives in the link fragment an ex-member keeps forever. So whether a removed member walks back in depends only on whether anything re-mints at that coordinate:

  • If nothing re-mints — today, since Amethyst mints a fresh link signer per invite and the Refounding neither re-mints nor revokes — stranded recovery never fires for anyone. It is dead code, and the cure drainConcordRekeys' own KDoc points to for "a BAN-holder can evict anyone (the owner included) by omission" does not exist. An owner evicted by a rogue admin has no way back.
  • If anything re-mints at a stable coordinate — which is what CORD-05's design describes, so plausibly Armada in a cross-client community — every removed member auto-recovers the new root and re-announces a Guestbook join, looking current again. The only hard removal is silently undone.

Note also that refoundConcordCommunity never revokes the links the removed member created or joined through, though ControlEntityKind.INVITE_REVOKED exists and classifyInvite honors it.

Fix. Decide the intended semantics first — this needs a spec answer. Then gate mergeForward on not being banned in the epoch we merge from, have the Refounding revoke the removed members' links, and either implement re-minting so legitimate recovery works, or drop the mechanism and give evicted owners another route.

A3 — The action layer has no permission checks; the UI's are ban-blind

Status: fixed. Authority now lives in AccountConcordActions.isAuthorizedFor, which every moderation verb funnels through, and every authorization test uses the ban-aware hasPermission.

High (defense in depth). Read: AccountConcordActions (banConcordMember, unbanConcordMember, editConcordMetadata, deleteConcordChannel, refoundConcordCommunity), ConcordMembersScreen, ConcordChannelListScreen.

Every moderation verb checks isWriteable() and the Control write key, and nothing else — no permission bit, no banlist. Authority lives entirely in the composable that draws the button. Two consequences:

  1. The UI's own gates are ban-blind. iCanBan, canEdit (metadata) and canManageChannels all use effectivePermissions, which ignores the banlist. A banned admin still sees the Edit and channel-management controls. Those particular editions are dropped by every client's fold (METADATA/CHANNEL are hasPermission-gated), so the result is a silently no-op control — which this codebase elsewhere explicitly calls out as worse than no control at all.
  2. Ban/Remove survive only because of a second, unrelated gate. canBan is viewerCanBan && canBanTarget, and canBanTarget routes through canActOn, which is ban-aware. Remove the second condition and a banned admin gets a working Ban button. That is a thin margin for a Critical-severity outcome (B2).

refoundConcordCommunity is the sharpest instance: its own guard is isOwner || effectivePermissions(me).has(BAN) — deliberately ban-blind — so a banned BAN-holder can launch a full community Refounding from the shipping app. Honest receivers refuse it (drainConcordRekeys checks the ban-aware hasPermission), so the blast radius today is noise plus self-stranding — but it is a race against banlist propagation, and a fresh joiner who has not folded the ban yet has no reason to refuse.

Fix. Move the authority check into the action layer where it cannot be bypassed by a new caller (desktop, CLI, a future screen), and switch every effectivePermissions used as an authorization test to hasPermission. Keep effectivePermissions only where the question really is "what do their roles say", independent of standing.

A4 — A banned member keeps broadcasting "typing", and we keep showing it

Status: fixed on both ends.

Low, both halves ours. Read: AccountConcordActions.sendConcordTyping, ConcordCommunitySession.ingestTyping.

The send side checks isWriteable() and nothing else, so a banned member's stock app keeps emitting kind-23311 heartbeats. The receive side checks that the rumor is a typing heartbeat, is bound to the channel/epoch, and is not our own — and nothing else. So a banned member sits in the "… is typing" row indefinitely, in a channel where every message they send is hidden. Cheap to fix on both ends, and it directly contradicts what a ban promises the user.

A5 — A banned member's own client keeps reading and rendering everything

Status: inherent; no code change. The cryptography cannot be fixed without a Refounding, so what is left is a product decision about how "Ban" and "Remove from community" are presented. Left for a design pass rather than guessed at here.

Medium, partly inherent. Read: CORD-02/05, ConcordCommunitySession.

Until a Refounding, a ban stops honest clients from showing the banned member's posts; it does not stop delivering the community's posts to them. Their stock app keeps subscribing, decrypting and rendering the whole community in real time. They also keep any invite links they hold (and can mint more — A1).

The cryptography here is inherent to a soft ban, but the product side is ours: "Ban" and "Remove from community" are very different promises and the UI presents them as neighbours in one menu. Worth making the difference explicit at the point of choice, and worth defaulting destructive moderation to the Refounding path.


Part B — requires a malicious client

The attacker writes their own events, so nothing client-side binds them. We can only refuse to honor what they publish.

B1 — One edition at Long.MAX_VALUE pins an entity forever

Status: fixed. The compaction arm tries the floor-anchored chain first and bounds the bootstrap jump at EditionFold.MAX_COMPACTION_VERSION_JUMP; compactControlPlane picks the chain head rather than raw max version. The three reproductions now assert the fixed behaviour, and aGenuineCompactionJumpIsStillFollowed pins the CORD-06 §3 tolerance the bound must not break.

Critical. Does not require a banned user, a sockpuppet, or the owner's absence. Unrecoverable.

Verified: quartz/…/cord04Roles/ControlPlaneVersionExhaustionTest.kt (3 tests).

Any current holder of an entity's permission bit publishes one edition at version = Long.MAX_VALUE. For every client that holds an EntityFloor for that entity, that edition becomes the permanent head:

  1. it wins, so the entity shows the attacker's content;
  2. authorizedHeads raises the entity's floor to Long.MAX_VALUE;
  3. no honest edition can ever exceed that floor, so the entity can never be repaired;
  4. a Refounding that drops the poison does not help — nothing is offered at or above the floor, the fold reports a gap, and falls back to EntityFloor.known, which is the poison.

The chain walk is not the weakness (it advances only to head.version + 1 citing the head's hash, so a fresh joiner is unaffected). The weakness is the compaction arm of EditionFold.foldEntity: once a floor exists and the entity is in the epoch snapshot — which fold always builds from the editions handed to it — the head comes from bootstrapHead, i.e. highest version at or above the floor, with no prev, no hash, no contiguity. Version becomes the whole contest.

Concretely: a moderator with MANAGE_CHANNELS deletes #general permanently for everyone; one with MANAGE_METADATA renames the community permanently. Demoting or banning them afterwards changes nothing — the damage is in every client's floor. ConcordRefounding.compactControlPlane also selects the head per entity by raw highest version, ungated, so an honest rotator carries the poison into every future epoch, where fresh joiners then anchor on it as their baseline.

The banlist survives, by accident: AuthorityResolver folds it on its own floor-less chain walk and re-heals the union across authorized editions, so an honest ban still lands. That accident is the only thing separating this from a permanently unmoderatable community, and it is now pinned by aPoisonedBanlistStillAcceptsTheOwnersBan.

Fix direction. The compaction arm needs a bound, since it is the arm that trades contiguity for cross-epoch tolerance. Options, roughly in order of preference:

  • Cap the version delta the arm will accept in one step (a compacted head is legitimately ahead of the floor, but by a chain's worth, not by 2^63). Anything above the cap is a gap, not a head.
  • Make bootstrapHead prefer the highest version reachable by a chain among the offered editions, falling back to raw version only when no chain connects.
  • Have compactControlPlane select the authority-gated fold head rather than raw max version, so a poison is at least not propagated by honest rotators.

The first is the smallest change and closes the unrecoverability; the third should happen regardless.

B2 — A banned staffer keeps Role, Grant and Banlist authority

Status: fixed. Not consensus-affecting after all — see Armada comparison. Armada already implements the same two-pass, so this brings us into line rather than out of it. One narrower divergence remains, described there. AuthorityResolver.resolve is now a bounded two-pass where authority only shrinks. Note the deliberate cascade it brings: every edition a banned member ever authored is dropped, so banning an admin also demotes everyone that admin promoted. That is the literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a later-banned admin vanishes with it and has to be re-issued.

Critical. Verified: quartz/…/cord04Roles/BannedStaffEscalationTest.kt (13 tests).

hasPermission is ban-aware; the resolver's internal gates are not, and structurally cannot be as written — the roles/grants fixpoint settles before banned is computed. So a banned member who still holds control_root keeps the roster. In the reproduction they:

  • ban every member they outrank, directly, with no puppet;
  • revoke the surviving moderators' grants and retire the roles beneath them;
  • mint a fresh, unbanned npub at the next position down, which then passes every ban-aware gate: deletes every channel, rewrites the metadata, bans the rest of the community, creates invites;
  • and, because drainConcordRekeys authorizes a rotator by hasPermission(rotator, BAN), that puppet can publish a Refounding omitting the owner — every honest client follows it and the owner is stranded on a dead root.

Self-unban is not reachable and neither is a puppet-unban: the delta rule gates removals and strict outranking means nobody outranks themselves, while no edition may claim a position at or above its signer, so the delegation chain only descends. Two hand-crafted attempts that avoid removal entirely — forking the banlist at genesis, and building a private chain — are also refused, by CORD-04 §4's re-heal union rather than by the rank rule. The union is load-bearing security here, not just convergence.

Fix direction. Make the resolver's gates ban-aware. The ordering problem is real (you cannot know who is banned before folding the banlist, nor who may write it before knowing who is banned), so resolve it as a bounded two-pass where authority only ever shrinks: pass A settles the roster as today and computes the banlist; pass B re-resolves roles/grants dropping editions whose author is banned in pass A; then recompute the banlist under pass B's roster, keeping only bans still authorized. Deterministic, terminates, no oscillation on mutual bans. Consensus-affecting: until Armada ships the same rule, we will drop editions they honor.

B3 — A rogue rotator compacts the banlist away

Status: mitigated by B2. The rotator this needed was the sockpuppet, which can no longer be minted. A legitimately privileged rotator can still omit the banlist, and EntityFloor remains the only defense for clients that already folded it — unchanged, and still worth a spec fix.

High. Verified: aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor.

A CORD-06 §3 compaction re-wraps one edition per entity and the rotator picks it, so a rotator can decline to carry the banlist forward. Every edition it serves is genuine, so no signature check sees the omission — EntityFloor's own KDoc names this case ("clearing a banlist"). A banned member cannot rotate, but the B2 puppet can.

The result is not a clean unban but a split community: clients that already folded the ban refuse the rollback and still see it, fresh joiners have no floor and see no ban at all. Two populations permanently disagreeing about who is a member, with no event either side can call forged. Closing B2 removes the puppet and takes this with it; floors alone do not, since they only protect people who were already there.

B4 — The Refounding recipient set is attacker-inflatable

Status: fixed (bounded). The recipient set is capped, the owner-rooted roster is kept first, and anything dropped is logged rather than silently truncated.

High. Read: ConcordCommunitySession.allMembers() / emitChannelRumors; AccountConcordActions.refoundConcordCommunity step 2; ConcordRefounding.buildBaseRekeyWraps.

allMembers() = Guestbook joins observedAuthors roster owner, and it is the Refounding recipient set. Both contributing sets are unbounded and both are attacker-writable: Guestbook joins are self-signed (any key, no authority), and every author we decrypt is folded into observedAuthors by design (CORD-02 §5, "observably present").

So each throwaway npub an attacker posts from, or announces, is one more mandatory NIP-44 blob in the next Refounding, chunked 120 per event. 100k identities ⇒ ~100k encryptions and ~830 published events — while they keep posting. The attack inflates the cost of its own remedy, and the remedy is the only hard removal Concord has.

This is the cheapest thing on the list to fix and the only one that is not consensus-affecting: cap the recipient set, prefer recent/attested members when over the cap, and surface what was dropped (a silent truncation strands real members). Worth doing first.

B5 — The ban is a per-pubkey display rule and the channel key is not revoked

Status: inherent. No client-side fix exists; a Refounding is the answer, which is why B4 mattered.

High. Read: Account.consumeConcordRumorGated (isBanned(rumor.pubKey)), Account.isAcceptable.

Writing to a channel needs the channel key, which the ban does not take away; the seal author is whatever key the client feels like using. A malicious client therefore posts every message from a fresh npub and isBanned never matches — moderation is whack-a-mole against an infinite identity supply. Each message also costs every member two NIP-44 decrypts and two signature verifications before the banlist check runs, and each fresh author inflates B4.

There is no client-side answer; only a Refounding rotates the key out from under them. That is the correct design, which is why B4 matters so much.

B6 — Channel history is deletable on a naive third-party relay

Status: correct on our relay and pinned; external relays remain exposed. Needs a CORD-01 spec note and relay-selection guidance, not code.

High, external. Verified (that we are safe): geode/…/ConcordPlaneKeyDeletionTest.kt (3 tests).

CORD-01 signs every wrap with the shared stream key, so on the wire a Concord channel is one author publishing everything — and every member holds that author's secret. NIP-09 and NIP-62 authorize on the outer pubkey. Read the obvious way, that hands any ex-member a one-event wipe of the whole community's history, and geode's own guarantee ("a kind-5 from pubkey X cannot delete pubkey Y's events") is vacuous inside a plane.

On our relay it is refused, but only because of a rule written for something else: Event.owner() gives a kind-1059 to its p-tag recipient rather than its signer, and ConcordStreamEnvelope stamps a freshly random p-tag on every wrap, so each wrap is owned by a one-time key nobody holds. Both halves are load-bearing, neither was written for this, and either one silently re-opens the hole — all three are now pinned, including a counterfactual showing a wrap addressed to a real key is deletable by its holder.

A community publishes wherever its metadata points. Any relay that authorizes deletion by matching pubkey still hands every ex-member the wipe button, and a Refounding protects only the future. Worth a note in the CORD-01 spec and a line in the relay-selection guidance.

B7 — The base-rekey plane is writable by every member

Status: accepted. Bounded work per wrap, no correctness impact.

Low. Read: ConcordKeyDerivation.baseRekeyAddress, AccountConcordActions.drainConcordRekeys.

The base-rekey address derives from community_root, so any member — banned included — can mint valid wraps there. Authorization happens after the blobs are scanned, so a flood costs every member a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact; listed for completeness.


Part C — interop and not-yet-shipped

C1 — Banlist rank rule diverges from Armada

Medium, known, deliberate. See docs/concord-banlist-rank-conformance.md, already reported.

We enforce §3's rank half on the Banlist and Armada does not, so the two clients can show different banlists. Shipped knowingly. Row 3 of that report ("a banned BAN holder unbans themselves") was left open as a fixpoint-ordering question — B2 is the general form of it, and the fix proposed there resolves both.

C2 — Voice rooms are key-gated, not roster-gated

Design-level; not currently reachable. Read: ConcordBrokerToken, CORD-07 §2.

Downgraded from High on review: ConcordBrokerToken and VoicePresence are referenced nowhere outside quartz, so Amethyst ships no Concord voice path yet. This is a note for whoever wires one up, not a live hole.

A member proves voice-room membership by signing a NIP-98 kind-27235 request with the channel's derived voice signer key, whose pubkey is the SFU room name. The broker is stateless and holds no community secret, so it cannot consult the Control Plane and has no idea a banlist exists. A banned member keeps that key until a Refounding, so they can join the voice room and stay in it. Nothing on the client side can evict them — kicking them from the UI does not kick them from the SFU. It would be the one place where a ban fails audibly, in real time, in front of everyone, so it is worth designing the roster check in before shipping rather than after.


Armada comparison (checked 2026-08-09)

Read against gitlab.com/soapbox-pub/armada at src/concord-v2/. Worth doing before shipping any of this, and it changed two conclusions.

B2 — they already do it, and we had it backwards. foldControlState (lib/control.ts) runs the same bounded two-pass: fold once, take the banlist, and if any edition was authored by someone on it, re-fold with those editions excluded. Independently arrived at, same shape, same CORD-04 §4 justification in the comment. So this change brings us into line with Armada rather than out of it, and the consensus warning in the earlier revision of this doc was wrong.

One real divergence remains, and it is ours to defend: Armada keeps pass 1's banlist as the final word ("the first pass's Banlist stays the final word"), while we recompute the banlist in pass 2. So a banned admin's mass-ban of everyone beneath them still stands in Armada and is dropped by us — the aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll case. Their stated reason is to stop the anti-roster erasing itself; ours is that an edition from a banned author should not survive its own author's removal. Both are defensible; ours closes an attack theirs leaves open, and the self-erasure they worry about is unreachable for us because the rank rule makes mutual bans impossible (only someone who strictly outranks you can ban you). Worth raising with them.

B1 — the same bug, unfixed, in exactly the same place. bootstrapHead (lib/version.ts:155) takes the highest version at or above the floor with no bound; headCandidates uses it for the compaction arm; pickHead then raises the stored floor to whatever won. That is the whole version-exhaustion chain. This is now the second bug both implementations share because both read the same section the same way, and it deserves the same treatment as the rank rule: a written report.

A1 — ours alone. Armada gates invite creation on CREATE_INVITE in both the hook (useInvites2.ts) and the page (canCreateInvite). We were the only client handing a banned member a working invite button.

A2 — different architecture, and it is better. Armada's catch-up is push, not pull: a privileged member sends a stranded member a direct invite carrying the fresher root (useDirectInvites2, catchUp), so a human authorizes each re-admission. useRekeyWatch2 merely reports { stranded: boolean } for the UI. They also ship useBanSelfRemove2: a banned member's own client silently drops the community from their private list — network-silent, deliberately narrower than rekey-exclusion, because "a rotation can be a mistake; a ban is a judgment". Our pull-from-my-own- old-link design is what made the bypass possible, and their per-epoch bundle refresh is what would have supplied the higher epoch to pull. Two ways forward: adopt a refresh of our own (restores liveness, keeps the pull design and its risk), or move to their push model (safer, and it is what the one existing implementation does).

C1 — still open on their side. banlistGate remains a bare isAuthorized(roster, author, owner, BAN): no rank check, no delta rule. The divergence from docs/concord-banlist-rank-conformance.md is unchanged.

A divergence in the other direction. Armada's banlist takes only the gated head's content — there is no §4 re-heal union. Ours unions in every authorized non-ancestor edition, which is what defeats the genesis-fork laundering attempt in BannedStaffEscalationTest. So we honor concurrent bans they drop. Worth a spec question about which is normative.

A4 — shared gap. No ban filter on typing there either.

B4 — not established. I could not locate a recipient-set bound in their rekey path, but I also could not locate the recipient-set construction itself with confidence, so treat this as unchecked rather than as a finding either way.


Performance of the fixes

Measured on the JVM with a synthetic Control Plane (throwaway benchmark, not committed — ConcordCommunityState.fold over 226 and 2059 editions, 200 reps after warmup). Pass A of the two-pass resolver is byte-for-byte the old algorithm, so the single-pass rows below are the before-numbers.

Case µs / fold
226 editions, no bans 1457
226 editions, 20 bans, none of them authors 994
226 editions, 20 bans, one an author → pass B runs 1881
2059 editions, no bans 2194
2059 editions, 50 bans, none of them authors 2001
2059 editions, 50 bans, one an author → pass B runs 5697
2059 editions, with floors (B1's compaction arm) 2015

Two things to take from it.

B1 costs nothing measurable. Trying the floor-anchored chain before the raw-version bootstrap adds a per-entity version index on the compaction arm, but an entity carries a handful of editions, and the floored fold measures the same as the unfloored one.

B2 costs a further fold, but only when it can change the answer. resolve skips pass B when nobody is banned or when nobody banned ever authored a Control edition — the overwhelmingly common shape, since bans land on plain members who hold no role and write nothing. Those rows show no regression. When a banned member did author editions — a banned staffer, exactly the case B2 exists for — the fold costs ~23× more, and one more pass again in the rare case where a banned member had themselves authored a ban. That is the price of the fix and it is paid only by communities under the attack.

Worth knowing, unrelated to this work: Amethyst re-folds the whole buffer from scratch on every Control Plane change, and resolve runs once per held epoch inside controlFloorsLocked plus once in fold, so a refresh is already several folds. Armada memoizes the fold by (community, owner, floors, snapshot, edition ids); we do not. That is the real optimization here, it predates these fixes, and it would also absorb the pass-B cost. Left alone deliberately — it is a change to make on its own merits, with its own measurements.


What was NOT examined

This audit is bounded by what was opened. Checked and found sound: the wrap/seal envelope (no author impersonation — rumor.pubKey == seal.pubKey and rumor.verifyId()), Concord chat edits (Note.latestConcordEdit is author-gated, so a member cannot rewrite someone else's message), and self-unban (B2).

Not looked at at all:

  • Private channels (CORD-03 derived keys) — key delivery on grant, and channel-scoped rekey. Note that no channel-scoped rekey receive path appears to exist: drainConcordRekeys handles ROOT_SCOPE only, and entry.privateChannels is carried forward but never populated by a delivery path. If that is right, the only removal Amethyst can perform is a full-community Refounding — which is exactly what B4 makes expensive.
  • In-plane reactions and deletes — the edit path is author-gated; the delete path was not read.
  • Guestbook kicks (kind 3309) — the builder documents a KICK-bit + rank rule; the receive side was not verified against it.
  • Unread counts and notification triggers, media/upload references from messages, the NIP-53 nests overlap, and the desktop client's Concord paths.

What is left

  1. A2's liveness half — decide whether a community re-mints its invite bundle at a stable coordinate. Today nothing does, so stranded recovery never fires for anyone, and an owner evicted by a rogue admin has no route back. Needs a spec answer before code.
  2. C1 / B2 interop — tell Armada about the two-pass rule, as with the rank rule before it. The divergence is now wider: we drop editions they honor whenever a privileged member is banned.
  3. B6 — a CORD-01 note that a plane's wraps must stay owned by a key nobody holds, plus guidance that a relay authorizing NIP-09/62 by pubkey hands every ex-member a wipe button.
  4. B3's residue — a legitimately privileged rotator can still omit an entity during compaction. EntityFloor catches it for clients that were present; fresh joiners have nothing.
  5. A5 — a design pass on how "Ban" and "Remove from community" are presented, since they promise very different things.
  6. The unexamined surfaces below, particularly private channels — there appears to be no channel-scoped rekey receive path at all, which would mean the full-community Refounding is the only removal Amethyst can perform.