Skip to content

moderation(L4): enforce bans/timeouts at auth, ingest, and connection seams#1594

Merged
tlongwell-block merged 2 commits into
eva/community-moderationfrom
dawn/mod-l4-bans
Jul 7, 2026
Merged

moderation(L4): enforce bans/timeouts at auth, ingest, and connection seams#1594
tlongwell-block merged 2 commits into
eva/community-moderationfrom
dawn/mod-l4-bans

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

L4 — community moderation enforcement seam (Phase 1)

Enforces bans and timeouts at the three points where a restricted member can act. Consumes L1's Db::moderation_restriction_state seam (PR #1592).

What's enforced

Auth seam (handlers/auth.rs) — a banned member's AUTH is rejected with OK false "blocked: you are banned from this community" and the connection is dropped. Includes a NIP-OA owner cascade: a crypto-proven owner whose owner-pubkey is banned is refused too. Fails closed on DB error.

Ingest write-block (handlers/ingest.rs) — a timed-out member's EVENTs are refused with restricted: you are timed out until <ts> until expiry. Moderation-command and relay-admin kinds are exempt so the tools that lift a timeout are never disarmed. Bans never reach here (dropped at auth), so this gate is timeout-only. Fails closed.

Live disconnect (state.rs) — ConnectionManager::disconnect_pubkey closes every live socket for a pubkey, delivering the close reason over the control channel (drained ahead of the biased cancel in send_loop) so the client learns why before the socket drops.

Cross-pod fan-out (buzz-pubsub/src/conn_control.rs) — a new Redis pub/sub channel carrying ConnControl::DisconnectPubkey, parallel to cache_invalidation rather than folded into it: a disconnect is an imperative, non-idempotent action, not a pure cache-key drop. main.rs spawns the subscriber + a consumer that applies inbound commands via disconnect_pubkey. The DB ban row is the durable backstop — even a dropped command still refuses the banned member's next auth attempt.

Lane boundaries

  • The 9040 handler that calls publish_conn_control + local disconnect_pubkey on ban creation is L6 (Quinn).
  • matched_principal audit at ban-creation is L1/L6.

Validation

Validated against the real L1 seam (overlay of max/moderation-l1 DB files, then restored):

  • cargo check -p buzz-pubsub -p buzz-relay -p buzz-db
  • 4 conn_control tests, 2 disconnect_pubkey tests, 88 ingest tests ✅
  • cargo fmt --check ✅ · git diff --check ✅ · pre-commit hooks ✅

⚠️ Merge order

This PR depends on L1 (#1592) for moderation_restriction_state. Base eva/community-moderation does not yet contain that seam, so CI will not compile until L1 merges into eva/community-moderation first; I'll rebase then. Per Eva's arbitration: no stacking, base = eva/community-moderation.

…tion seams

Phase-1 community moderation enforcement lane (L4). Consumes L1's
`Db::moderation_restriction_state` seam at three points:

- Auth seam (handlers/auth.rs): reject AUTH from a banned member with
  `OK false "blocked: you are banned from this community"` and immediately
  close the socket (decision 4). The reason frame is routed over the
  control channel and the send loop drains it ahead of the Close, so the
  client learns why before the socket drops. Includes a NIP-OA owner
  cascade — an agent whose crypto-proven owner is banned is refused too.
  A DB error fails closed but denies with `error: internal ...` rather
  than falsely claiming a ban.

- Ingest write-block (handlers/ingest.rs): a timed-out member's EVENTs
  are refused with `restricted: you are timed out until <ts>` until the
  timeout expires. A ban is also re-checked here: an already-authenticated
  connection never re-auths, so if the live-disconnect fan-out is missed
  (fire-and-forget publish, broadcast lag, subscriber reconnect window),
  this write-path gate is the durable backstop that stops a banned member
  writing indefinitely. Moderation-command and relay-admin kinds are
  exempt so the tools that lift a restriction are never disarmed. Fails
  closed. The gate checks the authoring pubkey only; the NIP-OA cascade is
  structural at the auth seam for bans, and timeout's owner cascade is a
  documented Phase-1 asymmetry (IngestAuth carries no auth tag).

- Live disconnect (state.rs): `ConnectionManager::disconnect_pubkey`
  closes every live socket for a pubkey *within the banning community*,
  delivering the close reason over the control channel. The community
  filter is the tenant fence: one pod holds sockets for many communities,
  so a ban in A must never kill the same member's session in B.

Cross-pod fan-out (buzz-pubsub/src/conn_control.rs): a new Redis pub/sub
channel carrying `ConnControl::DisconnectPubkey`, parallel to
cache_invalidation rather than folded into it — a disconnect is an
imperative, non-idempotent action, not a pure cache-key drop. main.rs
spawns the subscriber and a consumer that applies inbound commands via
disconnect_pubkey, passing the scoped community so the fence holds
cross-pod.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…pState

Adds `AppState::disconnect_pubkey_clusterwide`, the single entry point for
live ban enforcement: it closes this pod's sockets for the banned pubkey
(fenced to the community) and spawns the cross-pod `publish_conn_control`
fan-out so every other pod's subscriber closes its sockets too.

Closes the wire-up gap Quinn found: #1594 shipped both halves as separate
primitives (`conn_manager.disconnect_pubkey` + `PubSubManager::
publish_conn_control`) but nothing called the publish half, so a live ban
was pod-local-only — violating decision 4's "immediately, everywhere,
including live sessions". Rather than have the ban handler (L6) remember to
call both, pairing them on AppState (mirroring `spawn_cache_invalidation`)
makes "do half the job" unrepresentable: callers get cluster-wide
enforcement from one call and can't silently drop the fan-out.

The banning pod re-receives its own publish and no-ops (local sockets
already closed) — intentional, documented, no origin-suppression. The
return count is pod-local only; remote closes are async and unreported.
Fire-and-forget publish with the DB ban row as the durable backstop.

No dedicated unit test: pure composition of two already-tested primitives
(the fenced pod-local disconnect has three tests incl. the tenant-fence
case; publish + serde are covered in conn_control.rs). A full AppState +
live-Redis harness for a 15-line pairing is disproportionate.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
@tlongwell-block tlongwell-block merged commit 0bff742 into eva/community-moderation Jul 7, 2026
23 of 24 checks passed
@tlongwell-block tlongwell-block deleted the dawn/mod-l4-bans branch July 7, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant