Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices#1616
Merged
Conversation
…eams Community moderation build (design: docs/moderation/PLAN.md, decisions locked by Tyler 2026-07-07). This commit pins the cross-lane contract: - migrations/0006_moderation.sql: moderation_reports (NIP-56 queue), community_bans (ban + muted_until timeout), moderation_actions (audit). All tenant-scoped, community_id-leading keys per the isolation lints. - buzz-core kinds: KIND_REPORT (1984), moderation commands 9040-9044. - buzz-db::moderation: persistence contract (signatures + row types). - buzz-relay handler seams: moderation_authz (capability helper), report (1984 ingest), moderation_commands (9040-9044 dispatch), moderation_notices (relay-signed DM notices). Bodies are todo!() stubs owned by implementation lanes; signatures are the interface. Reports are signals, never triggers (NIP-56); targets resolve under the requesting TenantContext only. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The live-Postgres migration test hardcoded [1, 2, 3] and had rotted (migrations 4 and 5 already exist on main; it only passes when skipped). Derive the expected list from the embedded MIGRATOR so it stays honest as additive migrations land — including 0006_moderation. Verified against a fresh local Postgres: all 6 versions apply, and the three moderation tables materialize with community-id-leading keys. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Wren's contract review of 67373d9 (two blockers + pins): - 0006_moderation.sql: CHECK-enforce exactly-one-target-class per report row (target_kind authoritative, matching column only); 32-byte length checks on every pubkey/event-id/sha256 column; matched_principal restricted to self|owner; same-community FKs for channel provenance (reports+actions -> channels) and resolution provenance (reports.action_id -> moderation_actions). - buzz-core: KIND_REPORT + 9040-9044 registered in ALL_KINDS (duplicate detection now covers them); is_moderation_command_kind() as the one canonical route check; compile-time asserts (u16 fit, not ephemeral). - moderation_commands.rs docs: pinned 9040-9044 routing (community-global direct commands, is_global_only_kind, fresh timestamp, never stored, no channel-scoped tokens) and the exact tag vocabulary per kind so the CLI and relay seams cannot diverge. Verified on live Postgres: migration applies; a two-target insert is rejected by moderation_reports_check. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
A stray h tag must never channel-scope a moderation command; the 9040-series follows the NIP-43 9030-series model (executed, never stored, community-global). Closes Wren's contract-review mismatch: the note claimed this was landed at d02c9be but only the kind constants were. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The moderation_commands.rs module doc still carried the pre-review shape (report = row uuid, status escalated, resolution/public_reason tags). Pinned vocabulary (thread event 86f46207): report = 1984 event id hex, status resolved|dismissed, required action tag, optional reason -> moderation_actions.public_reason. kind.rs doc for 9044 updated to match. No code changes. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Implements the L1 moderation persistence lane: moderation_reports/community_bans/moderation_actions DB layer, Db wrapper seams, restriction_state enforcement read, report lookup by event id, and ignored Postgres-backed invariant tests (tenant fence, expired-ban+timeout, re-ingest idempotency, double-resolve). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
…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>
…live disconnect (#1594) * commit '47630276': moderation(L4): pair live-ban disconnect with cross-pod fan-out on AppState moderation(L4): enforce bans/timeouts at the auth, ingest, and connection seams Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Phase-1 community moderation lane L6: the moderation command dispatch and the three HTTP read endpoints that back the mod queue. Command handler (handlers/moderation_commands.rs), kinds 9040-9044: - 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout, 9044 resolve-report. - Every command routes authorization through the single `authorize_moderation_action` capability helper (L2) — never an inline role check — then performs its DB write (L1), records the audit row, and best-effort sends the relay-signed notice DM (L5). - Freshness gate rejects stale/replayed commands (never stored). - 9040 ban drives live enforcement via the paired `AppState::disconnect_pubkey_clusterwide` (L4): one call closes this pod's fenced sockets and fans the disconnect out cross-pod, so a live ban takes effect immediately, everywhere (decision 4). The DB ban row is the durable backstop for a dropped fan-out. Read endpoints (api/bridge.rs) + routes (router.rs): - GET /moderation/reports, /moderation/audit, /moderation/restricted. - Each mirrors the existing count_events shape: HOST-bound tenant, NIP-98 auth + replay check, then the mod-authz gate (ViewQueue) rather than plain relay membership. Reads resolve tenant-scoped rows (L1) only. Depends on L2's `authorize_moderation_action` body, still `todo!()` and human-gated: the authz-gated paths compile against the pinned signature but panic at runtime until L2 lands. Handler unit tests cover the pure helpers (tag parse, expiration vocab, report-tag validation) which do not hit authz. Validation on base 0bff742: fmt --check, check -p buzz-relay, clippy -p buzz-relay, test -p buzz-relay --lib all green (492 passed / 0 failed; 7 L6 handler tests included). git diff --check clean. Diff is three buzz-relay lane files only. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Two small fixes in handle_resolve from Eva's #1599 review, plain delta on 8db239b. F1 — orphan audit row on a lost resolve race: insert_audit ran before resolve_moderation_report, so two mods resolving the same report left an audit row behind the failed resolve. Check the already-fetched report.status == "open" before insert_audit and error early. The DB's WHERE status='open' stays the real guard; a comment notes the residual tiny window (audit row + failed resolve only) is tolerated. F2 — resolution rows indistinguishable from enforcement rows: a one-click resolve with action=ban wrote an audit row "ban", and the client's paired 9040 wrote a second "ban" enforcement row — double-count, decision-vs- enforcement ambiguity. Prefix the resolution decision row `resolve:ban`, `resolve:delete`, etc. dismiss_report and escalate stay unprefixed (escalate must remain queryable for the platform-safety lane). Module doc records the audit vocab. Validation on base 0bff742 (toolchain 1.95.0): fmt --check, clippy -p buzz-relay --all-targets -D warnings, test -p buzz-relay --lib all green (492 passed / 0 failed / 2 ignored). git diff --check clean. Delta is moderation_commands.rs only, +30/-2. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Implements `authorize_moderation_action` (the capability helper, plan §1), the single seam every moderation decision routes through so a future Moderator tier is a policy change, not a rewrite (decision 1). Authority model: - Community owner/admin (tenant-scoped `relay_members.role`) authorize every action in any channel of their community — the bridge `validate_admin_event` is missing today. - Channel owner/admin keep channel-local authority for DeleteMessage/Kick within `channel_id`. No current call site passes a `channel_id` (every L6 handler + the queue bridge pass `None`), so this branch is the contract seam for the future `validate_admin_event` wiring — building it to contract now makes that a one-line consult, not a helper rewrite. - Guard rail: an admin cannot ban/timeout the community owner or a fellow admin; only the owner may action an admin. Scoped to ban/timeout only — restriction-lifting (unban/untimeout) is unguarded because a banned admin can't self-unban (blocked at the auth seam before any command runs), so the only reachable case is lifting a fellow admin's restriction, which is benign, audited, and owner-reversible. The guard trips on a target *role* of owner/admin, never on a missing row, so a drive-by spammer who already left is still bannable. Tenant fence: both role reads (`get_relay_member`, `get_member_role`) filter on `tenant.community()` in SQL — authority never crosses tenants. The policy is factored into a pure `decide_authority` from resolved roles, exhaustively unit-tested (7 tests: owner all-actions, admin against non-privileged and privileged targets, non-member bannable, guard scope, channel-role delete/kick-only, member/stranger denied). The async wrapper is thin I/O glue that reads only the roles a given path needs. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
* commit 'e4e8f8c5': Phase-1 community moderation authorization seam (L2). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…. query Wren's L7 sweep (#1591) found a NIP-98 auth mismatch that 401'd `buzz moderation reports` and `audit` in normal use: the CLI signs the full request URL — query string included (`?limit=…&status=…`) — but the relay reconstructed the expected URL from the bare path only, and buzz-auth URL normalization preserves query strings, so they never matched. `restricted` was unaffected (no query). Fix: `authorize_moderation_read` now takes the request's raw query (axum `RawQuery`) and appends it verbatim to the path before building the NIP-98 expected URL, so the relay verifies against the actual request URI the client signed. Verbatim (not re-parsed) keeps the match byte-exact regardless of param order/encoding. `restricted` passes `None` and keeps its bare-path expectation. This is the direction Wren recommended: verifying the full request URL keeps future filtered reads honest. Regression coverage (4 tests, bridge.rs): a query-bearing GET verifies iff the expected URL carries the same query (`reports?limit=20&status=open`, `audit?limit=20`); an anti-regression control proves the same event is rejected with a URL mismatch against the bare path (the pre-fix behavior); and `restricted` still verifies query-less. A test-local helper mirrors the production query-reconstruction so the seam is pinned without a DB harness. Validation on base 8059413 (toolchain 1.95.0, PATH=repo bin): fmt --check, clippy -p buzz-relay --all-targets -D warnings, test -p buzz-relay --lib all green (503 passed / 0 failed / 2 ignored — 499 prior + 4 new). git diff --check clean. Delta is bridge.rs only, +157/-5. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…query (#1607) * commit '8bbc3dae': moderation(L6): verify moderation reads against full request URL incl. query Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/quinn/mod-read-auth: moderation(L6): verify moderation reads against full request URL incl. query
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
* commit '7c83d54b': Route moderation ingest events Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/max/mod-l3-ingest: Route moderation ingest events
L6 (Quinn), CLI half. Adds the `buzz moderation` command group and the NIP-43-style community moderation command builders it signs. buzz-sdk builders (kinds 9040–9044), tag vocabulary pinned by moderation_commands.rs: - build_moderation_ban (9040): p + optional expiration + reason - build_moderation_unban (9041): p - build_moderation_timeout (9042): p + required expiration + optional reason - build_moderation_untimeout (9043): p - build_moderation_resolve_report (9044): report + status + action + optional reason; validates status/action vocabulary at build time buzz-cli `moderation` group: - Mutations (ban/unban/timeout/untimeout/resolve) sign 9040–9044 and POST /events, mirroring the 9030-series relay-admin transport. Community is host-scoped, so no channel/community flag. - Reads (reports/restricted/audit) GET NIP-98-authed /moderation/* endpoints, because queue/audit rows are structured DB rows, not stored nostr events — serving them over a REQ filter would mean synthesizing fake events and threading a privileged authz check into the public read path. Read-endpoint server side pending Eva's lane arbitration; CLI shape is stable either way. - client.get_authed() helper for NIP-98 GETs. Tests: 12 SDK builder tests (tag shape, pubkey lowercasing, vocab rejection); command-inventory stability updated for the new group. cargo check + clippy clean; buzz-cli + buzz-sdk suites green. Contract base: 4c58489 on eva/community-moderation. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Wren's L7 re-clear found that build_moderation_{ban,unban,timeout,
untimeout,resolve_report} validated hex targets with check_hex_len(.., 64),
which is a *minimum*-length check (built for abbreviated git SHAs). The
relay's extractors (moderation_commands.rs extract_p_tag_bytes /
extract_report_tag) require exactly 64 hex. The CLI masked this by
pre-validating with validate_hex64, but a direct SDK caller could build and
sign a p/report tag with 65+ hex chars that the relay then rejects as
missing/invalid — the SDK vouching for an event the relay drops.
Switch to the existing exact-length helpers: check_pubkey_hex for the four
target pubkeys and check_hex_exact(.., 64, ..) for the report id. Both
return the normalized lowercase value, so the redundant inline
.to_ascii_lowercase() on the tag values goes away. Error variant for
short/overlong input moves from InvalidDiffMeta to InvalidInput accordingly.
Tests: add overlong-hex regressions for a target pubkey and the report id;
update the two existing short-input tests to the InvalidInput variant.
buzz-sdk 219 / buzz-cli 135, clippy + fmt clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/quinn/mod-cli: moderation(L7): CLI commands + SDK builders for reports/bans/timeouts/audit Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/quinn/mod-cli: moderation: SDK builders enforce exact-64-hex for p/report tags moderation: buzz-cli moderation command group + SDK 9040–9044 builders
* origin/main: docs(readme): add Getting started section routing install paths (#1606) fix(desktop): restrict shared-agent sync to dev data dirs (#1597) feat(desktop): restart-required badge from spawn-time config hash (#1602) feat(desktop): boot-time reconcile of managed agents to relay events (#1601) feat(desktop): canonical <PubKey> component — hover to view/copy full keys, owner "you" labels (#1589) fix(desktop): hydrate reactions for Inbox context messages (#1596) fix: cleanup old screenshots that my agents committed (#1598) chore(release): release Buzz Desktop version 0.3.46 (#1585) fix(desktop): preserve agent model/provider when persona snapshot fields are blank (#1583) feat(acp,desktop): identify and reap stale agent harness processes (#1582) feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state (#1581) fix(desktop): treat baked build env vars as satisfying required agent config (#1580) feat(desktop): add "Copy image" to image right-click context menu (#1579) fix(nest): use buzz-dev symlink name for dev builds (#1587) fix(composer): address image-editor follow-up nits on #1491 (#1565) fix(desktop): render black static boot screen (#1570) feat(agents): group activity tool bursts (#1571) feat(desktop): aggregated overview rail, commit detail page, and full breadcrumbs (#1573) fix(desktop): fetch profiles for reaction actors and thread-reply authors (#1550) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The pgschema provisioning path (scripts/start-*-relay.sh, fresh-DB CI) applies schema/schema.sql only and is blind to migrations/. Without this fold, freshly provisioned DBs lack moderation_reports/community_bans/ moderation_actions and kind-1984 ingest 500s (hit live during the Phase-1 demo). Verbatim body of migrations/0006_moderation.sql; verified by pgschema apply on a fresh DB + zero-diff pgschema plan against an AUTO_MIGRATE-built DB. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
kind:1984 report ingest is live since the Phase-1 moderation merge but the NIP-11 document didn't advertise it, so interop clients wouldn't discover report support (Quinn's e2e cap note). Adds 56 to SUPPORTED_NIPS with a mirror test. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
cb32add to
e2443f8
Compare
The Phase-1 community-moderation stack is built and reviewed; the working plan document has served its purpose. Replace it with a root-level vision doc in the register of the sibling VISION_*.md files: the report -> queue -> decide -> enforce -> audit -> notice loop, the community/platform two-layer split, and the honest edges (escalation is a hook, two roles, best-effort notices, no automod). Link it from VISION.md alongside the other sub-visions. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Community Moderation — Phase 1 (relay + DB + CLI)
Foundation of the moderation stack (design locked by Tyler 2026-07-07, vision + locked decisions in #buzz-moderation thread
cc031bd4, plan inPLANS/COMMUNITY_MODERATION_PLAN.md).What's here: NIP-56 report ingest (kind 1984, tenant-fenced targets),
authorize_moderation_actioncapability helper (owner/admin viarelay_members), community bans + timeouts enforced at the NIP-42 auth seam (blocked:/restricted:per NIP-01) with cluster-wide live-disconnect via Redis, NIP-OA cascade (owner ban → agents; agent ban agent-only),moderation_actionsaudit rows, enriched delete tombstones (40099 +action_id/reason_code/public_reason, spoof-gated), relay-signed moderation DM notices, report-queue commands (9040/9041/9042/9043/9044/9045), CLI (buzz moderation ...), schema0006_moderationfolded intoschema/schema.sql, NIP-11 advertises 56.Verification (all at head
cb32adde):docs/spec/MultiTenantRelay.tlaMOD-1..7, mutation-tested.Stack: this PR →
eva/moderation-ui(#TBD) → #1614 (U1 member surface) → #1615 (U2 admin surface). Merge bottom-up.Known Phase-1.5 follow-ups (non-blocking, tracked in thread):
matched_principal/auth-seam audit surface, direct delete-with-reason fan-out, 9000/9001 hex-length validation, member report submission UI (Phase 2).