mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
248 lines
15 KiB
Markdown
248 lines
15 KiB
Markdown
# 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 (9000–9022) | **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`.
|