Skip to content

moderation(L5): relay-signed moderation notice DMs#1590

Merged
tlongwell-block merged 3 commits into
eva/community-moderationfrom
sami/mod-l5-notices
Jul 7, 2026
Merged

moderation(L5): relay-signed moderation notice DMs#1590
tlongwell-block merged 3 commits into
eva/community-moderationfrom
sami/mod-l5-notices

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

L5 — Relay-signed moderation notice DMs

Fills the moderation_notices.rs scaffold (contract tip 4c58489b) per plan §0.3. One lane file changed.

What it does

send_moderation_notice(tenant, state, recipient, notice):

  1. DM channelstate.db.open_dm({relay mod key, recipient}), participant-hash idempotent ⇒ one thread per (community, user).
  2. Discovery — reuses emit_group_discovery_events on first use, which already emits kind:39000 with hidden / t=dm / p for DM channels (no reimplementation).
  3. Profile — relay-signed kind:0 "{host} Moderation" (replaceable) on first use.
  4. Notice — relay-signed kind:9 with h=<dm_channel_id> + an e tag naming the source id, inserted and fanned out via dispatch_persistent_event (same path as every other relay-authored event).

Idempotency

Per (source id, recipient): the e-tagged source id is queried before insert; an existing relay-authored notice for that id short-circuits. A crash-retry between insert and fan-out is a safe no-op.

Privacy (module invariant)

Bodies are built only from the notice's own sanitized fields (status / summary / public_reason). Never reporter identities, other reporters, or raw report notes.

Notes for reviewers

  • The ModerationNotice enum shape is unchanged from the scaffold (pinned contract).
  • "{Community}" resolves to tenant.host() — the communities table has no display-name column; host is the community's public identity.
  • Non-replyable v1: nothing wires inbound replies to this thread.

Verification

  • cargo check -p buzz-relay
  • cargo clippy -p buzz-relay --all-targets ✅ zero warnings
  • cargo test -p buzz-relay --lib moderation_notices ✅ 4/4 (source_id selection + per-variant body rendering / privacy)
  • Pre-commit hooks green.

Base: eva/community-moderation. Trailers: Co-authored-by + Signed-off-by Tyler Longwell.

npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc and others added 3 commits July 7, 2026 13:33
Fill send_moderation_notice per plan §0.3: reuse the participant-hash
DM model to get/create a two-party {relay mod key, recipient} thread,
emit kind:39000 discovery (hidden/t=dm/p via emit_group_discovery_events)
and a relay-signed kind:0 "{host} Moderation" profile on first use, then
insert a relay-signed kind:9 notice (h=<dm_channel_id>) and fan it out via
dispatch_persistent_event.

Idempotent per (source id, recipient): each notice carries an e-tag with
its report/action id; an existing relay-authored notice for that id in the
DM short-circuits, so a crash-retry is a safe no-op.

Privacy: bodies are built only from the notice's own sanitized fields
(status/summary/public_reason) — never reporter identities or raw notes.

Unit tests cover source_id selection and body rendering per variant.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
… unconditional discovery

Fold Wren's + Eva's #1590 review into the L5 notice lane (single-file,
no DB migration per the agreed constraint):

- Unhide-after-open_dm: open_dm only clears hidden_at for created_by (the
  relay key), so a recipient who hid the "{host} Moderation" thread never
  saw a later ban/resolution notice. Call unhide_dm(community, dm, recipient)
  after open_dm so a fresh notice resurfaces the thread — closed-loop trust.

- Opaque source tag: the source is a report/action DB-row UUID, not a
  32-byte event id, so the ['e', <uuid>] tag was semantically wrong and
  lies about referencing an event. Replace with a custom
  ['moderation_source', <uuid>] tag and match it in Rust via a new
  notice_already_sent() helper scoped to the recipient's own DM thread —
  no EventQuery.e_tags, no shared-file edit, no migration.

- Unconditional discovery + profile: the kind:0 profile and kind:39000
  discovery were gated on was_created. Because discovery is ?-propagated,
  a first-send failure left the retry with was_created == false and the
  thread permanently undiscoverable. Both are replaceable events, so emit
  them on every send — cheap, and replace_addressable_event makes it
  idempotent.

- Docs: idempotency is crash-retry safe, not concurrency-safe; hard
  per-source serialization is a noted follow-up (deferred per Eva/Wren).

clippy clean; 4 unit tests pass.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Wren's #1590 re-review doc cleanups + Eva's non-blocking window catch,
all one-file:

- send_moderation_notice rustdoc no longer reads concurrency-absolute:
  "Crash-retry safe per (action/report id, recipient) ... concurrent
  duplicate sends are not serialized in v1."
- ReportResolved.status field doc pinned to `resolved | dismissed` (the
  scaffold vocab); the defensive `escalated` match arm stays.
- notice_already_sent now passes limit: Some(1000). Matching is post-query
  in Rust, so the default 100-row window could let an old source id fall
  out of view and re-send a duplicate on crash-retry; 1000 is the existing
  query clamp and a practical per-user ceiling — no migration, no shared-file
  edit.

clippy clean; 4 unit tests pass.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
@tlongwell-block tlongwell-block merged commit 8693ba2 into eva/community-moderation Jul 7, 2026
29 checks passed
@tlongwell-block tlongwell-block deleted the sami/mod-l5-notices branch July 7, 2026 18:13
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