-- Community moderation (Phase 1): reports, bans/timeouts, audit actions. -- -- Design: PLANS/COMMUNITY_MODERATION_PLAN.md §0 (decisions locked by Tyler, -- 2026-07-07). All three tables are tenant-scoped: community_id NOT NULL and -- community-id-leading keys, per the tenant-isolation lints in -- crates/buzz-db/src/migration.rs. Report/ban targets are only ever resolved -- under the requesting TenantContext — no global lookups (MOD invariants, -- docs/spec/MultiTenantRelay.tla). -- ── NIP-56 reports (kind:1984 ingest) ───────────────────────────────────────── -- One row per accepted report event. Reports are signals, never triggers: -- nothing auto-actions on them (NIP-56). Reporter identity is visible to -- moderators in the queue but never revealed to the reported author. CREATE TABLE moderation_reports ( community_id UUID NOT NULL REFERENCES communities(id), id UUID NOT NULL DEFAULT gen_random_uuid(), -- The signed kind:1984 event id (stored for audit/idempotency). report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32), reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32), -- What was reported. Exactly one target class per row (CHECK-enforced below). target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')), target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32), -- Channel inferred from an in-tenant target event row, when resolvable. channel_id UUID, -- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other. report_type TEXT NOT NULL, -- Reporter's optional free-text context (mod-queue-only; never public). note TEXT, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')), resolved_by BYTEA, resolved_at TIMESTAMPTZ, -- moderation_actions row that resolved this report, if any. action_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, id), -- Exactly one target class per row: target_kind is authoritative and the -- matching column (only) is populated. Queue/action code never guesses. CHECK ( (target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR (target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR (target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL) ), -- Same-community channel provenance (channels are soft-deleted, never -- hard-deleted, so this FK cannot dangle). FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); -- Queue reads: open reports, newest first, per community. CREATE INDEX idx_moderation_reports_status ON moderation_reports (community_id, status, created_at DESC); -- Group-by-target for triage aggregation. CREATE INDEX idx_moderation_reports_target_event ON moderation_reports (community_id, target_event_id) WHERE target_event_id IS NOT NULL; CREATE INDEX idx_moderation_reports_target_pubkey ON moderation_reports (community_id, target_pubkey) WHERE target_pubkey IS NOT NULL; -- Idempotency: one row per report event per community. CREATE UNIQUE INDEX idx_moderation_reports_event ON moderation_reports (community_id, report_event_id); -- ── Bans + timeouts (one restriction row per member) ────────────────────────── -- Ban = connection block, enforced at the NIP-42 auth seam -- ("blocked: you are banned from this community") + join/ingest surfaces. -- Timeout = write-block only ("restricted: you are timed out until "). -- A row may be ban-only, timeout-only, or both over its lifetime. CREATE TABLE community_bans ( community_id UUID NOT NULL REFERENCES communities(id), pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), banned BOOLEAN NOT NULL DEFAULT false, -- NULL + banned=true ⇒ permanent. ban_expires_at TIMESTAMPTZ, ban_reason TEXT, -- Write-block until this timestamp; NULL or past ⇒ not timed out. muted_until TIMESTAMPTZ, mute_reason TEXT, -- Moderator who last modified this row. actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, pubkey) ); -- ── Moderation audit ────────────────────────────────────────────────────────── -- One row per accepted moderation action. Full detail (reporter identities, -- private reasons, matched NIP-OA principal) stays mod/audit-only; the public -- tombstone carries only action_id + reason_code + sanitized public_reason. CREATE TABLE moderation_actions ( community_id UUID NOT NULL REFERENCES communities(id), id UUID NOT NULL DEFAULT gen_random_uuid(), actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), action TEXT NOT NULL CHECK (action IN ( 'delete_message', 'kick', 'ban', 'unban', 'timeout', 'untimeout', 'dismiss_report', 'escalate', 'resolve:delete', 'resolve:kick', 'resolve:ban', 'resolve:timeout')), target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), channel_id UUID, -- Machine-readable rule/reason code (e.g. "spam", "community_rule_3"). reason_code TEXT, -- Sanitized, safe for the public tombstone. public_reason TEXT, -- Mod-only context; never leaves the audit surface. private_reason TEXT, -- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only, -- the client never learns which. matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, id), FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); CREATE INDEX idx_moderation_actions_created ON moderation_actions (community_id, created_at DESC); CREATE INDEX idx_moderation_actions_target_pubkey ON moderation_actions (community_id, target_pubkey) WHERE target_pubkey IS NOT NULL; -- Same-community resolution provenance: a report can only be resolved by an -- action row in its own community. Added after moderation_actions exists. ALTER TABLE moderation_reports ADD FOREIGN KEY (community_id, action_id) REFERENCES moderation_actions (community_id, id);