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
35 KiB
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: A1–A4, 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:
- Part A — reachable from stock Amethyst. A banned user opens the shipping app and taps a button, or our own client does it for them on a timer. These are straightforwardly our bugs, they need no attacker sophistication at all, and every one of them is fixable in this repo without touching the protocol or coordinating with anyone.
- Part B — requires a malicious client. The attacker writes their own events, so no client-side rule binds them. We cannot stop them from authoring anything; we can only refuse to honor it. Fixes live in the fold, the store, or the spec.
- Part C — interop and not-yet-shipped surfaces.
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.foldgates METADATA/CHANNEL/INVITE through the ban-awareauthority.hasPermission.AuthorityResolvergates ROLE/GRANT/BANLIST internally throughholdsManageRoles/bitsOf/effectivePermissionsOf, which are ban-blind. The UI gates througheffectivePermissions, 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_rootif 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
does — useLinkRefreshWatch2 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:
- The UI's own gates are ban-blind.
iCanBan,canEdit(metadata) andcanManageChannelsall useeffectivePermissions, 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 arehasPermission-gated), so the result is a silently no-op control — which this codebase elsewhere explicitly calls out as worse than no control at all. - Ban/Remove survive only because of a second, unrelated gate.
canBanisviewerCanBan && canBanTarget, andcanBanTargetroutes throughcanActOn, 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:
- it wins, so the entity shows the attacker's content;
authorizedHeadsraises the entity's floor toLong.MAX_VALUE;- no honest edition can ever exceed that floor, so the entity can never be repaired;
- 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
bootstrapHeadprefer the highest version reachable by a chain among the offered editions, falling back to raw version only when no chain connects. - Have
compactControlPlaneselect 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
drainConcordRekeysauthorizes a rotator byhasPermission(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 ~2–3× 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:
drainConcordRekeyshandlesROOT_SCOPEonly, andentry.privateChannelsis 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
- 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.
- 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.
- 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
pubkeyhands every ex-member a wipe button. - B3's residue — a legitimately privileged rotator can still omit an entity during compaction.
EntityFloorcatches it for clients that were present; fresh joiners have nothing. - A5 — a design pass on how "Ban" and "Remove from community" are presented, since they promise very different things.
- 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.