feat(cli)!: report ban enforcement as three states in debug bans - #547
Conversation
`riverctl debug bans` showed no indication of whether a ban still excludes anyone. Since deputy ban authority (#410) a ban can sit in state completely inert, so a moderator reading the list could conclude a member is kept out when they are free to walk back in. The first attempt at this used a bool sourced from `BansV1::ban_is_enforcing`. Review showed it collapsed, in any state a user can fetch, to `!members.contains(target)` and carried no authority information at all: a revoked-deputy ban displayed ENFORCING for the entire window before the target returned, which is precisely the window a moderator needs the warning. The honest answer has three cases, so report three: - `inert` the ban is definitively not keeping its target out. Either the target is a current member it fails to exclude (that person is in the room now), or it can never apply: the signature does not verify against the banner's current key, or it names the room owner, whom `is_ban_authorized` refuses as a target outright. - `enforcing` the banner holds an ABSOLUTE grant (owner, or owner-appointed global moderator). Neither reads the target's position, so both re-derive with the target absent and re-apply wherever they reattach. A real promise. - `undetermined` the target is gone and the banner's authority came from their POSITION relative to them (strict ancestor, or deputy of a non-owner ancestor), which cannot be re-derived while they are absent and resolves differently depending on who re-invites them. Not a promise either way. That third state is what makes the other two truthful, and it is buildable precisely because the absolute grants survive the target's absence. BREAKING CHANGE: the JSON field `enforcing: bool` is replaced by `enforcement`, a string enum of `inert` / `enforcing` / `undetermined`. The key is renamed rather than changed in place so the break is visible to consumers instead of a bool silently becoming a string. `banned_user_id`, `banned_by_id` and `banned_at_secs` are unchanged. Closes #472
…ngency Adds `ban_by_a_member_with_no_grant_is_undetermined_once_the_target_is_absent`, completing the absent-target matrix (owner and global-moderator bans stay Enforcing; strict-ancestor and no-grant bans become Undetermined). Records in `BanEnforcement::Undetermined`'s docs WHY a lapsed positional grant and no grant at all share one state: with the target absent they are the same case. A ban by the target's former inviter applies if they are re-invited under him and not otherwise, and a ban by someone with no authority applies on the mirror-image event. Neither is revoked; both are contingent, and splitting them would claim knowledge of who re-invites the target.
…undant Cleanup step 5 makes `ban_signature_matches_current_key` unconditionally true for surviving bans, which makes the gate look deletable. That reasoning holds only on the cleaned path: a full-state PUT reaches a client without cleanup having run, and `verify` deliberately skips a ban's signature when the banner was absent at bans-apply time. So a fetched state can carry a ban attributed to a current, fully-authorized member but signed by someone else. The gate must also precede the authority check, which knows nothing about who signed: for a forgery attributed to an owner-appointed global moderator it answers yes, so classification would report the forgery as Enforcing.
Independent review — boundary lensReviewed at
Confirmed by independent tracing
Finding 1 — the contested merge is forced by the inputs, not a judgement call
Consequence: Finding 2 — MEDIUM, fix before merge
The revoked-moderator case is this issue's headline scenario. With the target absent it renders Fix is a string. The existing pin only requires Finding 3 — highest-value additionA NOT ENFORCING row is not a dead row. Bans are add-only tombstones and Findings 4-5 — comment/string polish
On what is arbitraryThe docs frame the PR body corrections
[AI-assisted - Claude] |
Review findings on the three-state classifier, all in the output text and docs; no classification behaviour changes. The UNDETERMINED note claimed the banner's authority "came from a position in the invite tree relative to that target". `classify_ban` cannot know that. With the target absent, "banner was their inviter", "banner's grant was revoked" and "banner never had authority" are the same input: the `invited_by` edge died with the target's `AuthorizedMember`, and the superseded `member_info` record was collapsed by `dedup_to_canonical`. The claim is false for at least the revoked-global-moderator case, which is #472's headline scenario and lands here whenever its target has already left — so the note sent the operator to inspect the invite tree when the actionable fact was a revoked grant. The note now states contingency only, and carries the `member deputized-by` hint that was previously advertised on the inert note alone. Also: - Warn that a NOT ENFORCING ban is dormant rather than deleted. Bans are add-only and `banned_member_ids` re-evaluates every stored ban on each cleanup, so restoring a moderator's authority retroactively re-arms every ban they ever issued, ejecting those targets and their invitees. This command is the only place that can warn before a re-grant. - Stop `Inert` claiming such bans "can never apply at all". Only the owner-as-target case is permanent. `ban_signature_matches_current_key` fails for two reasons and the common one is benign — the banner is not a current member, so their key is unavailable — which reverses if they rejoin. - Add the omitted "names the room owner" cause to the inert note. - Correct the framing of why a lapsed positional grant and no grant share one state: it is not a judgement call, no classifier with these inputs could separate them. The real decision is not to track grant history. - Stop the enum docs calling the split "definitive vs contingent". It is target present vs absent; an inert present-target ban is contingent too, via the re-arming above.
Both errors were introduced by the commit that was itself responding to a warning about prose written at speed carrying new inaccuracies. Found by auditing that prose against the code rather than trusting it. 1. The operator warning said "Bans are never removed once stored". False: `post_apply_cleanup` drains bans under the `max_user_bans` cap (`room_state.rs:173`) and the step-5 sweep retains only bans whose signature still matches a current banner's key (`:395`). The load-bearing fact is narrower and is what the warning now says: losing enforcement is not what drops a ban, so a revoked deputy who remains a member keeps theirs stored, and every stored ban is re-evaluated on each cleanup. 2. `BanEnforcement::Undetermined` credited `dedup_to_canonical` with hiding a revoked grant. That is only the post-cleanup story, and `canonical`'s own docs say reads must not depend on dedup having run. The actual mechanism is `deputies_of` reading `MemberInfoV1::canonical`, which exposes only the highest-ranked record, so a superseded record carrying the grant is invisible either way. The conclusion is unchanged; the stated reason was incomplete. Also drops the same "add-only" overclaim from `Inert`'s rustdoc.
Follow-up to #540, which closed #472 with a boolean. Review of that PR showed the boolean could not carry the information the issue asked for. This replaces it.
Problem
#540 added
enforcing: bool, sourced fromBansV1::ban_is_enforcing. Reviewers tracedpost_apply_cleanupgate by gate and found that in any state a user can actually fetch, the flag collapses to!members.contains(target)and carries zero authority information:ban_signature_matches_current_key, so that gate is unconditionally true for any surviving ban;false;ban.rs:274-278returns baretruewithout ever callingis_ban_authorized. The authority call atban.rs:273is dead on that path.So the column told an operator whether the person was in the room, which
riverctl member listalready tells them.Worse for the issue's actual scenario: deputy bans alice, owner revokes the grant, alice has not yet returned. That displayed ENFORCING, indefinitely, and only flipped after she walked back in — by which point
member listshows her. #472's Problem section is "they may well be back in the room"; the boolean covered the "already back" half and missed the "free to come back" half, which is the half a moderator can still act on.It was also attacker-inflatable: any member can mint bans naming absent ids, each rendering ENFORCING and inflating the header count. The contract's own source documents this as an abuse vector at
common/src/room_state.rs:126-134(#413, Limitation 2).Approach
The honest answer has three cases, so report three. A ban can be provably dead, provably live, or contingent on something that has not happened yet, and collapsing the third into either neighbour lies in one direction or the other.
inertenforcingundeterminedThe classifier is one presence check plus a single
is_ban_authorizedcall, which also revives the authority call that was dead in #540:Three states are buildable, not just more honest. The two absolute grants in
is_ban_authorized(banner is the owner; banner is an owner-appointed global moderator) read nothing about the target's position, so they re-derive with the target gone. That is what makesenforcingexpressible for an absent target at all, and it is the hole in #540's "a stricter check would report every working ban as inert" reasoning. Only strict-ancestor and non-owner-deputy bans become undeterminable. That over-broad claim sat in aDo NOT switch thiswarning in the merged source; the warning is kept (a bareis_ban_authorizedstill must not land) but its reason is corrected.The room-owner case resolves here too: it lands in
inertrather than a permanent confident ENFORCING, because the owner cannot be re-invited into a position that would make the ban apply.Human output:
with a note per state, emitted only when that state is present so neither trains the reader to ignore it. Neither note claims where the target is: a full-state PUT bypasses cleanup via
verify, so an uncleaned state can hold an inert ban whose target is already gone (#540's note asserted "those users are in the room" unconditionally).One deviation from the review, flagged deliberately
The review asked for the remediation hint to become
deputized-by <room> <banned_user_id>for the target-deputized-the-banner cause. I checked the two commands' semantics incli/src/commands/member.rsand did not make that change:member deputies <room> <ID>lists who ID has deputized;member deputized-by <room> <ID>lists who has deputized ID.The step-4 guardrail is
deputies_of(target).contains(banner)— the target deputized the banner.deputized-by <banned_user_id>asks who deputized the banned user, which is unrelated. The hint staysdeputized-by <room> <banned_by_id>, which answers both inert causes in one command: an empty result means the banner holds no grant, and the banned user appearing in the result is the guardrail. The note now says to look for both. Happy to change it if I have misread the intent.A NOT ENFORCING ban is dormant, not dead
Added in review, and probably the most operationally useful thing in the PR.
Bans are add-only: nothing removes one once stored, and
MembersV1::banned_member_idsre-evaluates every stored ban against current state on every cleanup. A revoked deputy who remains a member keeps passing the step-5 signature sweep, so their bans persist indefinitely in an inert state.Which means re-granting that deputy retroactively re-arms every ban they ever issued — ejecting those targets and their whole invite subtrees at the next cleanup. An operator restoring a moderator's authority going forward would silently re-eject everyone that moderator had ever banned, including bans they may have considered long since undone.
debug bansis the only place that can warn before the re-grant, so the human output now does, under the inert note:This is also why the
inertrustdoc no longer says such bans "can never apply at all".The signature gate is load-bearing, not defensive
Stating this plainly because it is the piece most likely to be deleted later as redundant.
ban_signature_matches_current_keyruns in front of the match. Cleanup step 5 retains only bans that satisfy it, so on a cleaned state it is unconditionally true and looks like dead weight. That reasoning holds only on the cleaned path. A full-state PUT reaches a client without cleanup having run —verifyaccepts it, andverifydeliberately skips a ban's signature when the banner was absent at bans-apply time, since bans apply before members. So a state you can actually fetch may carry a ban attributed to a current, fully-authorized member but signed by somebody else.It also has to come first, before the authority question. The authority check knows nothing about who signed: for a forgery attributed to an owner-appointed global moderator,
is_ban_authorizedanswers yes, and the classifier would report a forged ban asenforcing. That is the #472 failure mode with an attacker holding the pen.Worth noting how this was found: it was not in the first version of this PR. My own mutation run showed that deleting the gate killed zero tests, because no fixture produced a mis-signed ban.
ban_signed_by_someone_other_than_its_attributed_banner_is_inertexists because of that, forging withsign_struct+with_signatureand asserting the contract does not exclude the target either. The reasoning above is now also recorded at the call site so the next reader does not re-derive the "redundant post-cleanup" conclusion and act on it.Where the boundary is drawn, and the calls that were not obvious
The reviewer asked specifically whether the three-state line is in the right place, so here are the judgement calls rather than a smooth surface. Two feel settled to me and two are genuinely arguable.
Not actually a judgement call: a lapsed positional grant and no grant at all share
undetermined. I first presented this as a deliberate design decision. It is stronger than that — no alternative is expressible.classify_banreceives(ban, members_by_id, member_info, owner_id, owner_vk), and with the target absent, "the banner was their inviter", "the banner was an owner-appointed moderator whose grant was revoked" and "the banner never had authority" are the same input tuple: theinvited_byedge died with the target'sAuthorizedMember, and the supersededmember_inforecord was collapsed bydedup_to_canonical. The real decision is "do not add grant-history tracking", which River does not keep and this command should not introduce.Consequence, stated so the mutation table is not read as claiming more than it does:
ban_by_a_member_with_no_grant_is_undetermined_once_the_target_is_absentcannot kill a mutation that the ancestor test does not already kill. Both reduce to "signature ok + target absent + no absolute grant → Undetermined", differing only in fixture history the classifier never reads. It is kept because pinning the fourth corner of the matrix is worth having explicitly, not because it is an independent pin.Settled: a ban naming the room owner is
inert, notundetermined.is_ban_authorizeddeniestarget == owner_idoutright before any grant, and no re-invite can change that, so it is permanently dead rather than contingent. It needs an explicit guard because the owner is not in the members list and would otherwise fall through the absent-target branch and read UNDETERMINED forever.Was arguable, now resolved against a fourth state: mis-signature folds into
inert. I raised this as possibly wanting a separate "suspicious" state. Review showed that argument was about the minority sub-case.ban_signature_matches_current_keyreturns false for two reasons, and the common one is benign: the banner is not a current member, so their key is simply unavailable to check against. A fourth state would therefore fire mostly on pruned-moderator rows, not forgeries. It would also be wrong about permanence — that case reverses if the banner rejoins with the same key. Both reasons belong ininert, and the docs now say so rather than framing the gate as forgery defence.Arguable:
enforcingcovers two things that differ in durability. With the target absent it implies an absolute grant and is durable across their return. With the target still present (only reachable in uncleaned state, since cleanup removes authorized targets) the grant may be positional and would lapse if that ancestor left. Both are truthfully "excluding them today", and the variant docs separate the two claims, but a reader who sees only the word could over-trust the present-target case. Splitting it would mean four states for a distinction that is invisible on the cleaned path, which seemed the worse trade.Corrected after review: the split is target present vs absent, not "definitive vs contingent". The enum docs originally claimed the cleaner epistemics. They do not hold: an
inertpresent-target ban is contingent too, since re-granting the banner's authority re-arms it, which is arguably likelier than the target being re-invited under some particular member. The boundary stays where it is (present vs absent is the informative split and the notes are present-tense and true), but the docs no longer overclaim, because a reader reasoning from "inert means definitively dead" would get the re-arming hazard wrong.Testing
21 tests in
cli/src/commands/debug.rs, all mutation-verified rather than assumed. Every mutation below was applied to the committed content and confirmed to fail:classify_banalwaysEnforcing/Inert/Undeterminedis_ban_authorized, false collapsed to Inert (the #540 shape)legitimate_ancestor_ban_becomes_undetermined_once_its_target_is_removed,each_ban_is_classified_independentlyabsolute_grants_stay_enforcing_with_the_target_absentban_naming_the_room_owner_is_inert_not_undeterminedban_signed_by_someone_other_than_its_attributed_banner_is_inert[UNDETERMINED]marker collapsed into[NOT ENFORCING]human_output_marks_each_state_distinctlyBanInfoinline with a hardcoded verdictbans_command_delegates_to_the_shared_helpersban_list_linesAddressing the specific gaps the testing lens found:
bans_command_delegates_to_the_shared_helpersis aninclude_str!source pin in the idiom already used bystorage.rsandidentity.rs, cutting the body atmod testsso the assertions cannot satisfy themselves. This is not hypothetical: feat(cli): show whether each ban still enforces indebug bans#540's salvaged first draft added the helper and left the arm buildingBanInfoinline.each_ban_is_classified_independentlybuilds a room holding one ban of each state and asserts the verdicts line up with the bans in stored order, so a classifier hoisted out of the closure, or one verdict stamped on every entry, fails.every_enforcement_state_has_a_distinct_json_spellingfor the wire spelling of all three variants.a_present_target_agrees_with_the_contracts_excluded_setties the verdict toMembersV1::banned_member_idsfor present targets, where the equivalence genuinely holds, and assertsUndeterminednever occurs there.Full riverctl lib suite: 300 passed, 0 failed.
cargo fmt --checkclean, no new clippy warnings.JSON field change (nothing published can break)
enforcing: boolis replaced byenforcement, a string enum ofinert/enforcing/undetermined.banned_user_id,banned_by_idandbanned_at_secsare unchanged.Labelled a breaking change in the commit trailer for accuracy, but it cannot break a consumer:
enforcingshipped only in #540, andgit tag --contains d36faf3ereturns zero tags. The latest release isriverctl-v0.2.5whilecli/Cargo.tomlis at 0.2.7, so the bool has never been in a published riverctl. The key is renamed rather than changed in place so that anyone trackingmainsees a missing field instead of a bool that silently became a string.