Files
buzz/docs/moderation/PLAN.md
T
c9c868b250 Community moderation UI: integration branch (data layer + member surface base) (#1617)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-07-08 13:49:54 -04:00

15 KiB
Raw Permalink Blame History

Community-Admin Moderation Plan

Goal (Tyler, 2026-07-01): let community admins moderate their own communities. Co-authored by Eva + Wren. Grounded against buzz-1321-review @ 86d6388 and RESEARCH/NOSTR_CONTENT_REPORTING_MODERATION.md. Re-verified against fresh REPOS/buzz-moderation-ref @ main 1f5ba5b (2026-07-07).

0. DECISIONS LOCKED (Tyler, 2026-07-07, #buzz-moderation thread e2a91af6)

All open calls below are now decided. The UX was refined by Eva + Wren to a co-signed 9/10+ (Wren's line-cited corner-check: event 295db04e; final presentation: event 4273365f).

  1. Roles: owner + admin only. No Moderator tier for now. (Tyler, event 559f838f.)
  2. Reporter identities visible to mods in the queue (never to the reported author).
  3. Relay-key resolution DM (Tyler, event 34de7dac): on report resolution the relay key authors a regular nostr message stored in the DB. Container (Wren-verified): relay/moderation key creates/reuses a two-party DM channel {mod key, user} via the participant-hash-idempotent DM model (buzz-db/src/dm.rs), emits 39000 discovery (hidden, t=dm, p tags), inserts relay-signed kind-9 with h=<dm_channel_id>. One DM thread per user per community; relay publishes kind-0 "{Community} Moderation" identity. moderation_reports persists reporter_pubkey. Same primitive carries actioned-author notices and timeout/ban issuance. Non-replyable v1; replies (v2) route to mod queue as appeals.
  4. Ban/timeout gate at the auth seam (Tyler, event 34de7dac): evaluated after NIP-42 verify, before pubkey allowlist / enforce_relay_membership (handlers/auth.rs — gate goes immediately after ~L91). Banned ⇒ OK false "blocked: you are banned from this community" + immediate WS close, zero further processing (blocked: is the NIP-01 standardized prefix; there is no banned: prefix; AUTH must be answered with OK per NIP-42). Live ban kill: cluster-wide disconnect-by-pubkey over Redis pub-sub; CLOSED "blocked: ..." per active sub, then socket close (needs a close-by-pubkey API on ConnectionManager). NIP-OA: gate checks the authenticating pubkey always + owner pubkey when present — owner ban cascades to agents; agent ban is agent-only; audit records matched principal, client never learns which. Timeout is a write-block, not a connection block: OK false "restricted: you are timed out until <ts>", desktop disables composer with countdown chip. No silent write-drops.

1. Where this sits — two moderation layers, not one

Discord's published model (safety/our-approach-to-content-moderation) is explicitly two-layer, and Buzz should mirror it:

Layer Discord Buzz Owner This plan
Platform safety Trust & Safety team: CSAM image-hashing → NCMEC, ML network detection, human investigations Relay operator: sha-match in S3, NIP-86 ban/takedown, NIP-62 vanish Block / relay operator Adjacent — Fizz's MODERATION_SAFETY_SKETCH.md owns it. We cite, don't duplicate.
Community moderation Server owners + volunteer mods: AutoMod keyword/spam filters, Warning System Community admins/mods: NIP-29 admin actions, report queue, in-community bans Community admins THIS PLAN.

The severe-safety class (CSAM) is never delegated to community admins — it's a platform-level hard-removal + legal-report path. Community moderation is the subjective, per-community rule enforcement layer on top.

2. What PR #1321 already gives us (the foundation)

#1321 is not a moderation PR — it's the multi-tenant substrate that makes per-community moderation safe:

  • Every scoped row carries a server-resolved community_id (never caller-supplied).
  • TenantContext is minted only on the host-resolution path and threaded through every scoped DB read + Redis publish — a proven cross-community isolation fence.

Implication: community-admin moderation authority is naturally tenant-scoped, but only if target resolution also stays inside the fence. An admin of community A can never reach community B's content when the query lists/deletes/bans with for_community(A). The dangerous edge is a new moderation signal whose target is a bare event id, address, pubkey, or blob hash. Therefore every new moderation table gets community_id, every new action takes &TenantContext, and every report/label target is resolved under tenant.community() before it enters a queue or action row. No new isolation primitive needed; reuse #1321's — but do not add any global target lookup in the report/label path.

3. What already exists (build-on, don't rebuild)

From crates/buzz-relay/src/handlers/side_effects.rs + buzz-core:

  • NIP-29 admin kinds, authorized in validate_admin_event:
    • 9000 put-user (add / assign role), 9001 remove-user (kick), 9002 edit-metadata, 9005 delete-event, 9008 delete-group, 9021/9022 join/leave request.
  • Role model (buzz-core/src/channel.rs): Owner > Admin > Member > Guest, plus Bot. is_elevated() = Owner|Admin gates elevated-role grants. Agent-owner delegation lets the owning human act.
  • 39000-series group-state mirrors (metadata/admins/members/roles).
  • NIP-51 mute list (kind:10000) constant exists — user-level, client-advisory.

4. The gaps (what this plan adds)

Gap A — No distinct Moderator role

Today the only elevated tier is Admin (can manage members + settings). Discord's model leans on a volunteer-moderator tier that can act on content/users but cannot reconfigure the community or manage other mods.

Recommendation after corner-check: ship v1 Admin-only for community-wide moderation, with an explicit capability helper, and defer a distinct Moderator role until product needs delegated volunteer moderation.

Why not add Moderator in v1:

  • Community-wide authority lives in the tenant-scoped relay_members table, not in channel MemberRole. Adding Moderator to MemberRole would migrate channel-local DB enum/API/UI/projection surfaces without actually creating tenant-wide authority.
  • Adding moderator to relay_members.role is possible, but still touches admin commands, membership announcements, UI, downgrade paths, and tests. That is extra surface before we have the basic report/delete/ban loop working.
  • NIP-29 interop is fine either way because roles are relay-defined/arbitrary, but clients that only reconstruct channel 39001 admins will not understand a community-wide moderator unless the relay explains/enforces it. A relay-signed tombstone is clearer than silently adding every community admin/mod to every group.
  • A capability helper now makes v2 cheap: later moderator is a role-to-capability mapping, not an authorization rewrite.

V1 capability grid:

Capability Community owner Community admin Channel owner/admin Member
Delete any message in community (9005) channel only own only
Remove/kick user (9001) channel only self
Ban user from community
Timeout/mute user in-community
Resolve reports (queue actions) optional channel-view
Assign/revoke community Admin
Edit community-level settings
Delete community / hard-delete channel (9008) channel owner only where already allowed

Open call for Tyler: is a distinct Moderator tier required in v1? My reviewed lean is no: ship Admin-only, but structure authorization around capabilities so adding moderator later is a narrow extension. If Tyler wants Discord-like volunteer mods on day one, add moderator first to relay_members.role (tenant-level), advertise it in 39003, and only then decide whether channel MemberRole also needs a Moderator.

Gap B — No NIP-56 reports (the report button)

The single biggest missing primitive. kind:1984 is referenced only as a CLI filter example; there is no ingest, queue, or UI. Without it, mods have nothing to act on — they can only delete what they personally see.

Pipeline (all tenant-scoped via #1321's fence; target resolution is the sharp edge):

client "Report" → kind:1984 event (p + e/x tags, report-type, free-text)
       │  ingest: validate_report_event (new, in side_effects.rs)
       ▼
  moderation_reports table (community_id, target_event/pubkey/blob,
       reporter, type, reason, created_at, status=Open)
       │  aggregate by target; weight trusted reporters (mods > members)
       ▼
  per-community moderation queue (buzz-cli / desktop admin surface)
       │  mod resolves: dismiss | delete (9005) | remove (9001) |
       │                ban | mute | escalate-to-platform
       ▼
  moderation_actions table (audit) + relay-signed tombstone (see Gap D)

Target-resolution rules to keep the tenant fence closed:

  • e target: look up the event only with tenant.community(); infer channel_id from that row. If missing in this tenant, reject or store as an unresolved report — never search other tenants by event id.
  • x blob target: resolve through tenant-scoped media references, e.g. (community_id, sha256) or (community_id, event_id, sha256). A bare SHA-256 can be shared across tenants and must not grant cross-tenant visibility/action.
  • p-only target: treat as a community-local report about that pubkey in the current tenant. It cannot imply a platform/global ban.
  • NIP-32 labels (1985) in v2 follow the same rule: labels are advisory inputs until their target is resolved under the current TenantContext.

Key policy (from NIP-56 + the RESEARCH doc): relays should not auto-moderate on random-user reports — they're gameable. Reports are a triage inbox, not an auto-takedown trigger. Exception: trusted-admin reports of CSAM/illegal class can fast-path to platform quarantine (Fizz's layer), not to community-admin discretion.

Gap C — No persistent ban / in-community mute

9001 remove-user is a kick — nothing stops re-join on an open channel. Add:

  • community_bans table (community_id, pubkey, actor, reason, expires_at NULL=perm).
  • Enforce at join (9021/9000 self-add) and at event ingest for the community.
  • Mute/timeout = time-boxed write-block (can read, cannot post) — a softer tool than ban, matches Discord's timeout. Same table, muted_until column.

Gap D — No "Warning System" (why-was-this-removed)

Discord tells users why content was actioned. Buzz already has the right primitive: handle_delete_event_side_effect soft-deletes the target, then emits a relay-signed kind 40099 system message with type: "message_deleted", actor, and target_event_id. Extend that rather than inventing a new 48000-range event for v1.

Recommended shape:

  • Public/in-context tombstone: relay-signed kind 40099, rendered as "Removed by a community moderator" without exposing removed content. Add safe fields: action_id, target_event_id, target_author, reason_code, optional sanitized public_reason, and maybe actor if moderator identity is intentionally visible.
  • Private author notice: separate p-gated/DM notification to the actioned user with the reason, community rule, and appeal/restore path. This is the closer Discord Warning System analog.
  • Internal action/audit row: full moderator, report ids, reporter identities, evidence, and unsafe details stay admin-only.
  • NIP-32 label: useful in v2 as an advisory/scanner signal, not the authoritative enforcement record. Labels can be consumed by clients; tombstones explain relay actions.

5. Standards to adopt vs. skip (from RESEARCH doc, cited to nostr-protocol/nips)

NIP Adopt? Role in this plan
NIP-29 groups (90009022) Yes — already have it. Extend authorization for community Admin + ban. Relay-enforced community roles + admin actions.
NIP-56 reports (1984) Yes — new. Gap B. User report button; interop-standard so other clients work.
NIP-32 labels (1985) v2. Distributed/automated moderation output; feeds queue as a signal. Client-advisory blur/hide.
NIP-51 mute (10000) Yes (exists). User-level self-mute — client-advisory, orthogonal to community moderation. Keep distinct from Gap C's community mute.
NIP-86 relay-admin API Platform layer (Fizz). Operator ban/takedown at event/pubkey/IP grain. Not community-admin scope.
NIP-62 vanish (kind:62) Platform layer. GDPR right-to-erasure; MUST fully delete + block rebroadcast. Legal, not community.
NIP-72 moderated communities Skip. Deprecated upstream in favor of NIP-29 (README marks it unrecommended).
NIP-09 deletion (kind:5) Keep (exists). Author self-delete only; advisory. Not a moderation tool for others' content.

6. Phased rollout

Phase 1 — community-admin MVP (this plan's core):

  1. Add a community moderation capability helper and extend validate_admin_event for community owner|admin authority (Admin-only v1 unless Tyler explicitly wants moderator).
  2. NIP-56 kind:1984 ingest → tenant-scoped moderation_reports, with target resolution under TenantContext.
  3. moderation_actions audit table + extended relay-signed kind 40099 tombstone for removals.
  4. community_bans (+ mute/timeout column); enforce at join + ingest.
  5. buzz-cli moderation queue commands (list/resolve/ban/mute) for admins.

Phase 2 — UX + trust weighting: 6. Desktop/mobile Report button + moderation queue surface. 7. Trusted-reporter weighting; report aggregation by target. 8. Warning-System notifications to actioned users.

Phase 3 — distributed signals: 9. NIP-32 labels (1985) as a queue input + client-advisory hide. 10. Cross-community shared blocklists (opt-in), platform-layer coordination.

7. Open calls for Tyler — ALL DECIDED 2026-07-07, see §0

  • Moderator role in v1, or Admin-only first? (§4 Gap A — reviewed lean: Admin-only v1 with capability seams.)
  • Ban grain: per-community only, or should a community ban be able to request a platform-level ban for severe cases? (Escalation path to Fizz's layer.)
  • Report visibility: are reports mod-only, or does a reporter see resolution?
  • Warning System shape: reviewed lean is relay-signed kind 40099 tombstone + private author notice; NIP-32 label is v2/advisory (§4 Gap D).

Sources

  • buzz-1321-review @ 86d6388: crates/buzz-core/src/kind.rs, crates/buzz-core/src/channel.rs, crates/buzz-relay/src/handlers/side_effects.rs.
  • PR #1321 description (multi-tenant community_id / TenantContext).
  • RESEARCH/NOSTR_CONTENT_REPORTING_MODERATION.md (cited to nostr-protocol/nips).
  • Discord: safety/our-approach-to-content-moderation (2024-03-15).
  • Fizz's docs/MODERATION_SAFETY_SKETCH.md (platform media-safety layer, adjacent).
  • Wren corner-check: PLANS/COMMUNITY_MODERATION_CORNER_CHECK_WREN.md.