Brings the NIP-5D napplet-host alignment up to current main, which had moved
98 commits ahead of the branch point (d3bd7a45b9). No conflicts: only one
commit on main touched napplet files in that window, and it was a broad
suspend-chain refactor rather than napplet work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 25s enrichment window re-posts a notification every time metadata for it
lands, and postStandard/postConversation only skip that when
NotificationUtils.wasDismissed says the user is done with the event. Only one
path ever recorded that: reading the note in-app. Swiping the notification
away, hitting "mark as read", or replying from the tray all just cancelled
the notification id, so the enricher happily put it back seconds later — and
kept a relay subscription and a wakelock open for it until the window
elapsed.
Every notification already carries a delete intent and its actions target the
same receiver, so thread the event id through them and mark it dismissed
there. Replies mark it only once the send succeeds, leaving a failed send free
to enrich and retry.
Also, in the same area:
- Pin the group summary's timestamp to the child's event time. It defaulted
to "now", and the summary is re-posted on every enrichment re-render, so
the group kept re-sorting in the shade while the user was reading it.
- Make the childless-summary scan a single pass. Now that every child ships
with a summary the active list is about twice as long and the pairwise scan
grew four-fold. Deciding what is a child by the summary flag instead of by
comparing ids also fixes the case where a child's id equals the summary's.
Findings from an independent review of this branch (a different model, per
CONTRIBUTING-WITH-AI.md). Two were real, and the first is a regression this
branch introduced.
compactControlPlane picked its per-entity head with a bare structural chain
walk, which is worse than the raw max-version it replaced. With no floor,
foldEntity anchors at the lowest-version edition carrying no `prev` — and after
a PRIOR compaction the genuine head's `prev` dangles into a trimmed epoch by
design. So a forged `version = 1, prev = null` decoy outranks a real v50→v52
chain, and because nothing in this path checks a signature it became the
entity's entire carried-forward state. A forged empty banlist would have erased
every ban at the next Refounding. Reproduced, then fixed by selecting the
owner-rooted authority-gated head — the same edition ConcordCommunityState.fold
would seat, so the new epoch starts where the old one left off, and an
unprivileged author cannot influence the choice at all.
recoverStrandedConcordCommunities derived its new ban gate with
`?.isBanned(..) == true`, which reads "not banned" when the session does not
exist yet or its first fold has not landed. The sweep runs on the revision tick,
so a banned member's own client would have hit that window on cold start and
recovered itself — the exact bypass the gate exists to stop. It now fails closed
and retries on the next sweep.
Also from the review: resolve() now warns when the ban fixpoint exhausts its
pass cap without settling, instead of silently returning a roster folded under a
mask that no longer matches its banlist; and banGate stops lowercasing the same
author three times.
Two review findings are accepted rather than fixed, and recorded on the PR: the
anchor tie-break picks the lowest rumor id before testing whether that candidate
connects (pre-existing, and changing it is consensus-affecting), and non-owner
moderators now need a resolved roster before a verb succeeds, which is the
intended fail-closed trade.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
Android 16 force-groups notifications the app leaves loose. A group child
whose summary is missing counts as ungrouped (GroupHelper.
isGroupChildWithoutSummary), and with config_autoGroupAtCount at 2 it takes
one such child plus one other ungrouped notification in the same shade
section to form an aggregate bundle.
We produced both halves. sendGroupSummary only posted the summary once two
children of a group were live, so a lone repost or reaction sat there as a
summary-less child; and the always-on relay service posts an ongoing,
IMPORTANCE_LOW notification, which shares the Silent section with those two
IMPORTANCE_LOW kinds. The system's aggregate summary inherits
FLAG_ONGOING_EVENT from any child that has it, so the resulting bundle could
not be swiped away without dismissing the service notification.
Post our own group summary from the first child on, which keeps the group
app-owned and off the system's list. It changes nothing visually: the shade
hides any group with fewer than two children and renders the child on its
own.
That promotion is also why the summary now needs cleaning up: a promoted
child is no longer "the only child in its group", so dismissing it leaves
the summary behind, and a childless summary is both shown standalone by the
shade and force-grouped by the system. Children now carry a delete intent
that prunes it, the mark-read and inline-reply paths prune through
cancelAndPrune, and the pruning ignores an id cancelled moments ago (cancel
and notify are asynchronous, so activeNotifications can still list it) and
leaves the platform's own aggregate summaries alone.
Summaries also gain GROUP_ALERT_CHILDREN so they stay silent now that they
go up alongside the first child.
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
The two-pass ban-aware fold doubles resolve(), which runs once per held epoch in
controlFloorsLocked plus once in fold, on a client that re-folds the whole buffer
from scratch on every Control Plane change. So the second pass is now skipped
unless a banned member actually authored a Control edition — not merely when the
banlist is empty. Bans overwhelmingly land on plain members who hold no role and
write nothing, and for those pass B is provably identical to pass A. Armada's
fold checks the same condition.
Measured over ConcordCommunityState.fold (throwaway benchmark, not committed;
226 and 2059 editions, 200 reps after warmup). Pass A is byte-for-byte the old
algorithm, so the single-pass rows are the before-numbers:
226 eds, no bans 1457 us
226 eds, 20 bans, none authored 994 us
226 eds, 20 bans, one authored -> pass B 1881 us
2059 eds, no bans 2194 us
2059 eds, 50 bans, none authored 2001 us
2059 eds, 50 bans, one authored -> pass B 5697 us
2059 eds, with floors (B1's arm) 2015 us
So the common case is free, and B1's chain-first compaction arm is not
measurable — the floored fold matches the unfloored one. A banned staffer costs
~2-3x, which is the price of the fix and is paid only under the attack.
The audit records this, plus the standing opportunity it surfaced: we have no
fold memoization where Armada does, which predates this work and would absorb
the pass-B cost too. Not done here — that is a change to make on its own merits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
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
B2 in docs/concord-soft-ban-audit.md, plus B4's bound and the audit's status pass.
hasPermission was ban-aware; the resolver's own ROLE/GRANT/BANLIST gates were not,
and could not be as written — the roles/grants fixpoint settled before `banned`
was computed at all. So half the Control Plane honored a ban and half was blind
to it, and a banned staffer still holding control_root kept the whole roster:
banning everyone beneath them, revoking the surviving moderators, retiring the
roles under them, and minting a fresh un-banned npub that passed every ban-aware
gate and finished the job.
resolve() is now a bounded two-pass where authority only ever shrinks. Pass A
resolves as before and yields a candidate banlist; pass B re-resolves with every
author on it treated as holding no authority. Two passes always, so it terminates
by construction, and mutual bans cannot oscillate because the rank rule makes
them unreachable — only someone who strictly outranks you may ban you, and you
cannot outrank them back. A chain-local rule would not have worked: forking the
banlist at genesis means no parent ever mentions the ban and §4's re-heal union
carries it in regardless, so the rule is a whole-pass mask rather than a
per-edition check.
This cascades, deliberately: every edition a banned member ever authored is
dropped, grants included, 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. Both the cascade and its blast radius are pinned, and
the trade-off is written up in the Armada report as the answer to its own open
row 3 — which also widens the divergence recorded there: we now drop editions
they honor wherever a privileged member was banned.
B4: the Refounding recipient set is capped. allMembers() is the Guestbook ∪
observedAuthors ∪ the roster, and the first two are unbounded and
attacker-writable, so each throwaway npub someone posts from became one more
mandatory blob in the next Refounding — the attack inflating the cost of its own
remedy. The owner-rooted roster is kept first and anything dropped is logged,
never silently truncated, because a dropped member is stranded.
The nine escalation reproductions now assert the fixed behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
B1 in docs/concord-soft-ban-audit.md, the worst item on the list: one edition at
version = Long.MAX_VALUE permanently pinned its entity to the author's content,
for every client holding a floor for it, with no way back. The floor rose to
MAX_VALUE, no honest edition could exceed it, and a Refounding that dropped the
poison fell back to EntityFloor.known — the poison. Authored in the tests by a
current, legitimately granted moderator: no ban, no sockpuppet, one ordinary
permission bit.
The chain walk was never the weakness; it advances only to head.version + 1
citing the head's hash, so a fresh joiner was untouched. The compaction arm was:
it trades contiguity for cross-epoch tolerance, which left VERSION as the only
contest an edition had to win.
Two changes. The arm now tries the floor-anchored chain first and falls back to
the raw-version bootstrap only when nothing connects, so a stray never wins a
fold where the honest chain is present. And the bootstrap will not follow a jump
of more than MAX_COMPACTION_VERSION_JUMP above the floor — a compacted head is
legitimately ahead by a chain's worth, not by 2^63 — so the version space cannot
be exhausted in a step. A new test pins the tolerance the arm exists for, so the
bound cannot later be tightened into breaking CORD-06 §3.
compactControlPlane picked its per-entity head by raw highest version too, which
made an honest rotator the delivery mechanism: a disconnected stray never joins
the chain but won that comparison, and was re-wrapped into the new epoch as the
entity's whole history, where fresh joiners anchor on it. It now picks the chain
head, keeping foldEntity's fresh-joiner fallback for the dangling `prev` a prior
compaction leaves behind.
The three reproductions now assert the fixed behaviour. The banlist's escape
hatch (a floor-less chain walk plus the re-heal union) is kept and still pinned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
Part A of docs/concord-soft-ban-audit.md — the ones a banned user reaches by
tapping a button, no custom tooling involved.
A1. mintConcordInvite checked that the account was writeable and that we held
the community, and nothing else, while its button was the one control on the
screen with no gate at all. A member banned a minute ago could hand out a
working link to the community they were removed from, and every account they
invited arrived as a fresh un-banned npub. Now gated on CREATE_INVITE, both in
the verb and on the button. Worth noting the bit was not enforced anywhere else:
the fold gates the INVITE_* Control entities on it, but a link's bundle is a
standalone kind-33301 published outside the Control Plane, so this check is the
only one that exists.
A3. Every moderation verb checked isWriteable() plus the Control write key —
which is a spam gate, never authority (CORD-02 §5) — and left the real decision
to whichever composable drew the button. Those gates then tested
effectivePermissions, which ignores the banlist, so a banned staffer kept seeing
the controls; the editions were dropped by everyone's fold, making them silently
no-op, which this codebase elsewhere calls out as worse than absent. Ban and
Remove survived only because a second, unrelated condition happened to route
through the ban-aware canActOn. Authority now lives in the action layer behind
isAuthorizedFor(), so a caller from desktop, amy or a future screen inherits it,
and every authorization test uses hasPermission. refoundConcordCommunity's own
guard was ban-blind outright and now rank-checks each removed member too.
A2. The recovery sweep merges us onto any higher-epoch bundle found at our
stored invite_ref, and an ex-member keeps that link's unlock token forever — so
our own background timer walked a removed member back into the epoch a
Refounding had rotated them out of. isStranded/mergeForward now take
bannedAtCurrentEpoch as a required argument rather than leaving it to callers,
because a caller that forgets it inverts the mechanism. The liveness half of
that finding (nothing re-mints at a stable coordinate, so legitimate recovery
never fires either) needs a spec answer and is untouched here.
A4. Typing heartbeats are filtered on both ends, so a banned member stops
announcing that they are typing messages nobody will see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
`onDrained` was the wrong shape. It reported the one ending a coverage
caller happens to need and threw the rest away, so a CLOSED and an idle
timeout still arrived indistinguishable from a clean finish — the very
conflation this branch set out to remove, just moved one step along.
`fetchAllPages` now returns `PagedFetchResult(downloaded, end)`, where
`end` names every way the loop can stop: DRAINED, LIMIT_REACHED, IDLE,
CLOSED, CANNOT_CONNECT, UNPAGEABLE. `drained` stays as a shorthand on the
result so the meaning lives in one place. A caller can no longer ignore
the reason by accident, and the two failure endings are now reportable
rather than silently swallowed.
I argued for the callback on the grounds that ~25 call sites use the
`Int`. That was overstated: most call it as a statement and never touch
the return. Six needed a `.downloaded`, all mechanical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TNy5BsU9NErXYa3UNGTeJ
A paged band records the events it SAW, never the range it asked for, so
`legs()` can only ever say "walked this far" and keeps re-asking the leg
below the floor. Against a relay whose corpus for one kind simply starts
later than the others' that leg is unclosable: it comes back empty every
cycle, an empty fetch earns no band, so the floor never moves. Measured on
a live mirror of five NIP-65 indexers, three were in that state — kind
10002 re-walked from the beginning of time to Feb 2023 forever, because
relay lists did not exist before then.
The missing fact is why a page ended. `fetchAllPages` treated all three
terminal signals as one bare `Unit`, so an empty page could not be told
apart from silence or a CLOSED. It now carries a PageEnd, and reports
`onDrained` only for the one ending that proves absence: an EOSE on a page
that returned nothing, with no filter capped by its `limit` and no `search`
filter in play (both stop the walk short of the corpus). An idle timeout is
silence, not an answer, and recording it would durably claim coverage the
relay never served.
A callback rather than a richer return type: ~25 call sites across quartz,
geode and downstream use the `Int`, and none should have to change to learn
a fact they do not want. It follows `onNewPage`'s shape.
`SyncCoverage.record` takes `drained` and marks the kinds that produced
evidence complete — which required completeness to move from Band onto
Span. It could not stay on the band: once kinds diverge, `legs()` hands
each group its own ask, so a walk that drained `kinds: [10002]` proves
nothing about kind 0, and a band-level flag set from that leg would claim
both. That is the same over-claim per-kind spans exist to prevent, one
level up. `Band.complete` stays as a DERIVED all-kinds-complete, so both
state files keep writing the flag a pre-per-kind reader expects, and read
it back as every span's default.
A kind the walk never saw at all still earns nothing: there is no interval
to anchor a claim to, and inventing one would be the over-claim again.
Tests: five in NostrClientFetchAllPagesDrainTest pinning EOSE-empty vs
silence vs CLOSED vs cannot-connect vs a fulfilled limit, and five in
SyncCoverageTest for per-kind completeness, widening, and the deeper-floor
escape hatch that a drain must not defeat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TNy5BsU9NErXYa3UNGTeJ
Re-reviewed every finding against the shipping app rather than against the
protocol, and split the list in two: what a banned user can do with stock
Amethyst (our bugs) versus what needs a hand-written client (fix in the fold, or
defend against). Several items moved, and the review turned up a new one that
belongs at the top.
A1 is new and is the realistic attack. mintConcordInvite checks only that the
account is writeable and that we hold the community — no CREATE_INVITE, no
banlist — and unlike the Edit and channel buttons next to it, the invite
IconButton carries no guard at all. A banned user stays in the app, taps
person-add, and shares a working link to the community. The mint publishes a
fresh link signer, so revoking the links they were given does not touch the ones
they make; and because the bundle is a standalone kind-33301 outside the Control
Plane, the CREATE_INVITE bit the fold enforces on INVITE_* entities never
applies to the actual invite mechanism.
A3 is the general form: every moderation verb checks isWriteable() and the
Control write key and nothing else, so authority lives in the composable that
draws the button — and those gates use effectivePermissions, which is ban-blind.
Ban and Remove survive only because a second, unrelated condition routes through
the ban-aware canActOn. refoundConcordCommunity guards itself with
effectivePermissions outright, so a banned BAN-holder can launch a Refounding
from the shipping app; honest receivers refuse it, but that is a race against
banlist propagation, not a check.
A2 moves to Part A because our own client is what performs it: the recovery
sweep runs every 15 minutes with no banlist check.
C2 (voice) is downgraded from High — ConcordBrokerToken and VoicePresence are
referenced nowhere outside quartz, so there is no shipping path to attack. It is
a note for whoever wires one up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
The first pass was bounded by the Control Plane, the fold and the relay. Three
more findings from the surfaces it skipped, plus an explicit list of what is
still unexamined so the next reader knows where the edges are.
V10 is the serious one, and it forks. ConcordStrandedRecovery.isStranded takes
only (entry, bundle): no banlist check, no check that we were legitimately
re-keyed. The whole test is "the bundle at my stored invite_ref 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. Amethyst mints a fresh link signer per
invite and the Refounding neither re-mints nor revokes, so today nothing does —
which means stranded recovery never fires for anyone, and the cure that
drainConcordRekeys' KDoc points to for "a BAN-holder can evict anyone, the owner
included, by omission" does not actually exist. If any client does re-mint at a
stable coordinate, as CORD-05's design describes, then every removed member
auto-recovers the new root on the 15-minute sweep and re-announces a Guestbook
join. Either the safety net is missing or the only hard removal is undone;
which one it is needs a spec answer, not a patch.
V11: voice rooms authenticate with the channel's derived voice signer key
against a stateless SFU that holds no community secret and cannot know a banlist
exists, so a banned member keeps talking until a Refounding. V12: ingestTyping
filters on binding and self only, so they keep showing as "typing".
Checked and sound, recorded so they are not re-audited: the envelope pins
rumor.pubKey == seal.pubKey (no author impersonation), and Note.latestConcordEdit
is author-gated, so a member cannot rewrite someone else's message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
Collects the findings from this branch into docs/concord-soft-ban-audit.md,
each marked Verified (a test reproduces it, named) or Read (follows from the
code, untested), with a suggested order of attack.
Adds the reproduction for the one finding that was still unverified, and it did
not hold up the way it was first described. Version inflation does not poison
the anti-rollback floor through the chain walk — that walk advances only to
head.version + 1 citing the head's hash, so a fresh joiner is untouched. It goes
through the COMPACTION ARM: once a client holds a floor and the entity is in the
epoch snapshot, the head comes from bootstrapHead, which is highest-version at
or above the floor with no prev, no hash and no contiguity. Version is then the
whole contest and Long.MAX_VALUE wins it permanently — the floor rises to
MAX_VALUE, no honest edition can exceed it, and a Refounding that drops the
poison falls back to EntityFloor.known, which is the poison.
That makes it the worst item on the list: unrecoverable, and authored in the
tests by a current, legitimately granted moderator — no ban, no sockpuppet, one
ordinary permission bit. compactControlPlane picks per entity by raw max version
too, so honest rotators carry it into every future epoch.
The banlist escapes only because AuthorityResolver folds it on a floor-less
chain walk and re-heals the union, so an honest ban still lands. That accident
is all that separates this from a permanently unmoderatable community, so it is
pinned by its own test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
The ban/unban verb is not the threat model — a malicious client writes editions
directly, so what matters is which routes the FOLD refuses. Three more, all of
them ones the UI would never author.
Two are refused, and it is worth pinning why, because neither is refused by the
rule you would expect. Removing yourself from the banlist is caught by the delta
rule's strict outranking (nobody outranks themselves), so the sharper attempt
does not remove anything: it forks the banlist at genesis, or builds a private
chain, that simply never mentions him, at a version high enough to win the head
fold. There is then nothing to remove and the rank rule never fires. What
catches it is CORD-04 §4's re-heal — the owner's edition is not on the forged
head's back-chain, so it is unioned back in as a concurrent ban. The union is
load-bearing security here, not just convergence.
The third works. A §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 and no signature check can see the omission. A banned member
cannot rotate — drainConcordRekeys gates the rotator on the ban-aware
hasPermission — but the puppet from the previous commit is not banned and can.
EntityFloor is the entire defense, so the community splits: clients that already
folded the ban refuse the rollback, fresh joiners have no floor and see no ban.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
A soft ban leaves the community_root in the ex-member's hands, so they keep
deriving the channel's stream key. CORD-01 signs every wrap with that shared
key rather than with the author, so on the wire a Concord channel looks like a
single author publishing everything — and NIP-09/NIP-62 authorize on the outer
pubkey. Read naively that hands any ex-member a one-event wipe of the whole
community's history, and geode's own Nip09DeletionTest guarantee ("a kind-5
from pubkey X cannot delete pubkey Y's events") would be vacuous inside a plane.
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. Each
wrap is therefore owned by a one-time key nobody holds, attacker included.
Neither half was written with this attack in mind and either one silently
re-opens it, so both are pinned: two tests fail if ownership ever moves back to
the signer, and a counterfactual (a wrap addressed to a real key IS deletable
by its holder) fails the moment that p-tag becomes anything a member holds.
Scope: this is our relay's rule, not the protocol's. A third-party relay that
authorizes deletion by matching pubkey still hands every ex-member a wipe
button, and a Refounding only protects the future.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
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
Follow-up to #3882, which made RelayReachabilityStore edit a relay's
kind:30166 rather than rebuild it. toDiscoveryEventTemplate was the
remaining co-writer: it builds from the verdict alone, so a consumer
following its own KDoc — sign with the monitor key, insert — wipes
whatever else is on that address, undoing the merge for exactly the
writer #3882 set out to protect.
It now takes the current record and carries across every tag the verdict
did not measure, on the same rules:
- Ownership is per writer, and this one measures more than the store
does. A write probe determines `pow` from the OK message, so `R pow` is
its own finding and must not be re-dated from an older record. Without
a ReadWriteVerdict it never exercised the write path, so the same tag
is somebody else's and is carried across untouched — hence the
hasReadWrite flag rather than a fixed set.
- Both polarities of each requirement are owned, so an update cannot
leave the record asserting `pow` and `!pow` at once.
- created_at is max(requested, current + 1): a store enforcing
replaceable semantics rejects anything not strictly newer, and the
probe would be lost with nothing to show for the round trip.
The parameter defaults to null, so every existing caller keeps today's
behaviour and the change is additive.
Test plan: ./gradlew :quartz:jvmTest — 4,081 tests, all passing. Three
new cases in RelayProberFlowTest: a foreign tag and an unmeasured `R pow`
surviving a probe without a write verdict, a stale `R pow` being replaced
when the write path DID run, and the stamp landing past the record it
replaces.
Second review pass on the merge itself. Three of these reverse choices
made in the previous commit; the reasoning there was wrong.
The created_at cap is gone. Capping the bump to a window past `now`
looked prudent and was worse: a record already further ahead than the cap
can then never be replaced, because every stamp we are willing to write
is older than what is stored, so the relay's live/dead verdict freezes
until the wall clock catches up — 24h in the test that shipped asserting
that behaviour as correct. It did not even buy the freshness it claimed:
snapshot() selects on `since` alone, so a future-stamped record sits
inside the window either way. A record ahead of the clock is a defect in
whatever produced it; this class's job is to keep updating it.
Ownership is now the full liveness set on every write — `n`, all three
rtt types, and both polarities of `R auth` — rather than the narrower
per-path sets. A 30166 carries ONE created_at, so a tag carried across is
re-dated as a current measurement: keeping a rtt-read from an earlier
observation beside a fresh rtt-open republishes a stale latency as
today's, which aggregators rank on, and RelayObserver documents exactly
how wrong a queued rtt can be. Carrying `R auth` forward was worse still
— only an observation can clear it and that needs the connection the flag
discourages, so it became permanent, a regression against the rebuild
this PR replaced. Owning only the positive auth form also let `R !auth`
survive while `requirement("auth")` appended the opposite, publishing a
record asserting both.
The per-relay guard no longer swallows. It caught Exception, which
includes CancellationException, so a shutdown flush wrapped in
withTimeout — the pattern RelayMonitor.close() prescribes — could not
abort and would grind through every remaining relay. And a caught failure
went nowhere: collectUnreported() has already cleared the observation
flags by then, so the measurement is lost for good while the run reports
success. Cancellation now propagates, every relay is still attempted, and
the first real failure is rethrown once the loop finishes.
Also corrected a comment: the 16,507-relay figure is measured in
RelayObserver, not RelayProber, and the SQLite ceiling is verified here
rather than quoted — 32,765 `d` values pass, 32,766 fails.
Test plan: ./gradlew :quartz:jvmTest — 4,078 tests, all passing. Four new
cases: a future-stamped record still updatable, a stale rtt-read not
re-dated, an auth wall not outliving its observation, and a run whose
writes all fail reporting failure instead of success.
Five issues from a review pass on the previous commit, all in the new
merge path.
currentRecords() bound one SQL host parameter per relay with no
chunking. Callers pass the whole relay universe — RelayProber's own
measurement puts that at 16,507 — and a bundled SQLite refuses past
32,766 variables. The throw lands BEFORE anything is written, so an
entire probe run's records are lost rather than one relay's. Chunked at
500, in the same range as the author chunking elsewhere.
The created_at bump had no ceiling, so a stamp that once landed in the
future was sticky: every later edit derived from the bad value and never
re-anchored to now. Such a record never ages out of snapshot()'s TTL
window (an isKnownDead verdict that can never expire) and relays
enforcing future-timestamp limits reject every publish for it. Capped at
60s past now — a pathological record now costs the updates made while the
clock catches up, and heals itself.
writeOne owned all three rtt names but only ever measures rtt-open, so
the reachable path deleted rtt-read/rtt-write taken by an observation —
the exact silent loss this change exists to stop. It now owns rtt-open
alone; only the dead path clears them all, which liveness semantics
require.
writeObserved owned the whole R tag name but can only prove `auth`, so it
erased `R pow` and friends written by RelayProber. Ownership is now per
VALUE, which is why edit() takes a predicate rather than a set of names.
The read-modify-write spans a store round trip and IEventStore exposes no
read inside a transaction, so a concurrent writer to the same address can
still win the race and get our stale insert rejected. That cannot be
closed at this layer; it is now isolated per relay so one loser does not
end the loop and silently drop every relay after it.
Test plan: ./gradlew :quartz:jvmTest — 4,075 tests, all passing. Four new
cases, one per fixable finding: a flush wider than one chunk writing
every relay, a far-future record not being pushed further ahead, a
reachable update keeping latencies it never measured, and an observation
clearing only `auth`.
A kind:30166 is addressable, so RelayReachabilityStore keeps exactly one
record per (monitor, relay) — but it is not necessarily the only thing
writing per-relay knowledge under that identity. Both write paths built
the record from their own tags and inserted it, so every update deleted
whatever else was in that slot. Observed while adding a "this url is an
alias of that one" tag alongside the monitor: `[d, n, rtt-open]` became
`[d, redirect]` on our write, and the monitor's next observation turned
it back into `[d, n, rtt-open]`. Nothing looks wrong at any point — the
event still signs, still parses, still reads as a valid NIP-66 record. It
just says less than it did, and the reader downstream cannot tell.
Writing is now an edit: read this monitor's current record, carry across
every tag the writer does not own — including tags this version of quartz
has never heard of — and replace only what it measured. `n` and the three
`rtt-*` types are owned by both paths, so a dead update still clears a
stale rtt and liveness keeps meaning what it meant. `R` is owned only by
the observation path, which is the one that learns whether a relay
challenged us; writeOne leaves it alone rather than deleting what it
cannot re-measure.
Only OUR records are merged. Folding another monitor's tags into a
document signed with this key would republish their claims as ours.
The timestamp is now `max(now, current + 1)` rather than `now`. A store
enforcing replaceable semantics REJECTS a record that is not strictly
newer than the one it replaces, and two writers inside the same second —
or a peer whose clock runs ahead — are ordinary. That is not theoretical:
it silently swallowed a repair pass in the caller that found this bug,
which reported success having written nothing.
The reads are batched per call rather than per relay, so a flush over N
relays costs one extra query, not N.
Test plan: ./gradlew :quartz:jvmTest — 4,071 tests, all passing,
including four new cases in RelayReachabilityStoreTest covering a foreign
tag surviving an update, an update against a record stamped an hour
ahead, a dead update clearing its rtt, and another monitor's record not
being merged. ./gradlew :quartz:spotlessApply clean.
A staff-making Grant delivers the `control_root` inside the fold itself
(CORD-04 §3), so it lands on an entry whose session was built as a
read-only member long before. `ConcordSessionRegistry.sync` only rebuilt a
session when `root`/`rootEpoch` changed, and `ConcordCommunitySession`
derived `controlKeys` once at construction — adoption changes neither, so
the live session kept `signer = null` and `canWrite == false` for the rest
of the process.
The promoted staffer saw their new role badge appear (that half reads the
folded `state` flow) while every write affordance stayed hidden and
`controlKeysForWrite` refused, until the app was restarted.
Rebuilding the session on the change is not the fix: the new session starts
with no buffered Control Plane wraps, so the community folds to "No channels
yet" until every wrap happens to be re-delivered. Instead refresh the key
material in place. Nothing about the plane moves — adoption is gated on the
secret deriving to exactly the `control_pk` already held (CORD-02 §5) — so
the address, read key, buffered wraps and subscription set are all
invariant, and only the signer appears. `adoptControlMaterial` fails closed
on a different community/root/epoch or an address change, leaving those to a
rebuild.
Verified on device (SM-T220, Android 14) against a loopback geode relay: an
account promoted to staff while sitting on the community screen gains the
edit/create affordances with no restart, keeps its folded channel list, and
its next Control edition lands on the wire signed by `control_pk`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ResourceUsageStore.persist() rewrites the whole file on every merge, and
merges fire on the accountant's 30s flush debounce while traffic flows.
Only today's bucket ever changes, so retention is a write-amplification
setting as much as a history setting. Nothing reads past 7 days.
- fix(resourceusage): atomic subscription map, and build the report off Main
- fix(resourceusage): bound the technical dump so the report stays sendable
- an orphan KDoc in ResourceUsageReportAssembler with no declaration
under it, which Kotlin silently bound to formatBytes
- paragraphs in ResourceUsageAccountant and ResourceUsageStore claiming
the key space has no fixed upper bound; every remaining counter is
compile-time bounded
- the user-facing privacy string, which claimed the report contains the
host names of the relays that reconnected most. It does not, and that
file is Crowdin-bound, so the false claim would have reached
translators.
The only way out of the Resource Usage screen was "Send report via DM",
which builds the text into a draft message to a fixed pubkey. Reading
your own report meant opening a composer and copying out of it.
Adds Copy and Share beside it, handing over the same string for a bug
report or a file. Reuses Clipboard.setText from ClipboardExt and the
ACTION_SEND chooser pattern from ShareActions.
The ledger could say how much relay data the app moved, but not why. It
counted completed connections and a single undifferentiated byte total,
so "1.65 GB/day across 6,600 connects" could not be broken down further,
and relay.connfails was being read as a dial-failure count when it also
fires for mid-session drops of successful connections.
Adds, all as counters with no behaviour change:
relay.dials / relay.disc real dial and disconnect counts
relay.life.<bucket> connection-lifetime histogram, bucketed to
straddle STABLE_CONNECTION_IN_SECS
relay.verb.up/down.<verb> the byte totals split by protocol verb
relay.purpose.<p>.* REQ bytes, inbound bytes and frames by the
SubPurpose that asked, read off the
ExplainedFilter that already travels on the
filter
relay.subs.* REQs sent, closed, replayed after connect,
and re-sent for an already-open subscription
relay.events.* inbound EVENT frames and how many carried an
event already delivered
relay.notice.<reason> NOTICE frames by an allowlisted reason
relay.hs / relay.gap the transport's own handshake timing, and
everything before the request went out
relay.trigger.<cause> which decision asked for a reconnect
LocalCache implements both Dao and ICacheProvider, which disagreed on the
parameter names of getOrCreateUser (hex vs pubkey) and
getOrCreateAddressableNote (address vs key), so every override warned about
named-argument mismatches. Align both interfaces on pubkey/address and update
the implementations that used the other name.
Also drop the non-null assertions the compiler already smart-casts away in
LimitsPolicy.capLimits and RelayProberFlowTest, and match the WebSocketListener
parameter names in NegentropyStallRepro.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017X7C797zGYsiui5yj1JQcY
`export`/`restore` handed back `Map<String, Band>` where the string was
the INTERNAL key — `"<relay-url> <filter-json>"`. That is fine for a file
layer that writes the key back verbatim, and nothing else. A layer that
wants its own layout — one object per relay, or per filter, or nested by
both — had to split the key apart, and the separator was folklore it
could only learn by reading this class. Two of them now do.
So the key is a pair, with the joined form kept here as `encode`/`decode`
for a file that does want one key per line. geode keeps its format
byte-for-byte and stops pattern-matching on somebody else's string.
It is also faster on the path that matters. `key()` built a new string
per lookup, so a `legs()` over a fan-out COPIED the filter's json — tens
of thousands of characters for an author-scoped filter — once per relay
per cycle, then hashed all of it, since a freshly built string carries no
cached hash. The pair hashes two halves it already holds: the url, and
the fingerprint instance the cache above it already returns.
No behaviour change: the same pairs key the same bands, a file written
before this reads back through `decode`, and the format on disk is
untouched.