From 9df5e9e7212612f24654a57c302afee6fff500f7 Mon Sep 17 00:00:00 2001 From: Matthew Bradley <168114+mbradley@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:37:12 -0400 Subject: [PATCH 1/4] fix: correct MOD namespace reason normalization and rule matching Bridge: add NS/VI/AI aliases to _normalize_report_reason so moderation-service kind 1984 reports map to canonical values (nudity, violence, ai_generated). Rule: fix ModerationServiceFlag to match normalized canonical values instead of 'ai_generated'/'deepfake'/'self_harm'/'offensive' (which never matched). Remove BanNostrEvent -- moderation-service uses ['p', sha256] not a real pubkey, so ReportedPubkey/ReportedEventId are unusable for enforcement. Signal only: flag_for_review + ai_classified label. Enforcement stays in ai_classification.sml and label_routing.sml which have real Nostr identifiers. --- divine/nostr-kafka-bridge/main.py | 6 +++ .../rules/reports/moderation_service.sml | 40 ++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/divine/nostr-kafka-bridge/main.py b/divine/nostr-kafka-bridge/main.py index 8a0d65c0..2fdb927e 100644 --- a/divine/nostr-kafka-bridge/main.py +++ b/divine/nostr-kafka-bridge/main.py @@ -54,6 +54,12 @@ # Other 'false-information': 'other', 'NS-other': 'other', + # MOD namespace labels from moderation-service kind 1984 reports. + # These are the raw l-tag values: NS (Not Safe), VI (Violence), AI (AI-generated). + # The bridge receives them lowercased after strip().lower() in _normalize_report_reason. + 'ns': 'nudity', + 'vi': 'violence', + 'ai': 'ai_generated', } diff --git a/divine/rules/rules/reports/moderation_service.sml b/divine/rules/rules/reports/moderation_service.sml index f14688e5..a2878c2d 100644 --- a/divine/rules/rules/reports/moderation_service.sml +++ b/divine/rules/rules/reports/moderation_service.sml @@ -1,20 +1,24 @@ -# Divine Moderation Service Auto-Ban (kind 1984 reports) +# Divine Moderation Service Signal (kind 1984 reports) # # Handles kind 1984 events published by moderation-service for automated -# classifications AND human moderator overrides. Both use NOSTR_PRIVATE_KEY -# and the MOD namespace with labels NS/VI/AI. +# AI classifications. Uses NOSTR_PRIVATE_KEY with MOD namespace labels NS/VI/AI. +# The bridge normalizes these to 'nudity', 'violence', 'ai_generated'. # -# This is one of two paths for moderation-service output into Osprey: -# - Kind 1984 (this file): automated AI flags + human override reports -# - Kind 1985 (content/label_routing.sml): human-verified label events +# This rule is a SIGNAL only -- it flags content for human review but does +# not enforce bans directly. Two reasons: # -# The kind 1984 reports use the MOD namespace. Content JSON includes -# scores, type, and source ('ai' or 'human-moderator'). +# 1. moderation-service kind 1984 events use ['p', sha256] (video hash, not +# a real pubkey) and have no 'e' tag, so ReportedEventId is empty and +# ReportedPubkey is a sha256. BanNostrEvent with those identifiers would +# fail or produce incorrect bans. # -# NOTE: ReportReason values below still don't match the actual MOD -# labels (NS, VI, AI). These need alignment with the kind 1984 tag -# structure. The rule currently won't match because it checks for -# 'ai_generated' etc. but the reports use 'NS', 'VI', 'AI'. +# 2. Enforcement with real Nostr identifiers is handled by ai_classification.sml +# (which operates on actual video events and calls the moderation API directly) +# and label_routing.sml (which fires on kind 1985 human-verified decisions). +# +# This path is one of two for moderation-service output into Osprey: +# - Kind 1984 (this file): automated AI signal, routes to human review +# - Kind 1985 (content/label_routing.sml): human-verified decisions, enforces Import( rules=[ @@ -23,19 +27,19 @@ Import( ] ) -ModerationServiceBan = Rule( +ModerationServiceFlag = Rule( when_all=[ Kind == 1984, HasLabel(entity=Pubkey, label='moderation_service'), - ReportReason in ['ai_generated', 'deepfake', 'self_harm', 'offensive'], + ReportReason in ['nudity', 'violence', 'ai_generated'], ], - description='Divine moderation service flagged content for permanent ban', + description='Divine moderation service flagged content for human review', ) WhenRules( - rules_any=[ModerationServiceBan], + rules_any=[ModerationServiceFlag], then=[ - BanNostrEvent(event_id=ReportedEventId, pubkey=ReportedPubkey, reason='Content flagged by moderation service'), - DeclareVerdict(verdict='auto_ban'), + LabelAdd(entity=EventId, label='ai_classified'), + DeclareVerdict(verdict='flag_for_review'), ], ) From 9fda84fa036ca8c59158f027a94cdb6198091e1d Mon Sep 17 00:00:00 2001 From: Matthew Bradley <168114+mbradley@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:08:11 -0400 Subject: [PATCH 2/4] docs: stub ticket resolution architecture in ZendeskSink --- divine/plugins/src/services/zendesk_sink.py | 30 +++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/divine/plugins/src/services/zendesk_sink.py b/divine/plugins/src/services/zendesk_sink.py index 0b4aa40f..f2ef6d06 100644 --- a/divine/plugins/src/services/zendesk_sink.py +++ b/divine/plugins/src/services/zendesk_sink.py @@ -105,11 +105,31 @@ def _create_ticket(self, verdict: str, result: ExecutionResult) -> None: logger.exception(f'Failed to create Zendesk ticket for verdict={verdict}') def _log_resolution(self, verdict: str, result: ExecutionResult) -> None: - """Log resolution verdicts. Resolving existing tickets requires - searching by event ID, which needs the Zendesk search API and - a tag/field convention for linking tickets to Nostr events. - Not implemented yet -- would need to match the relay-manager - pattern (zendesk_tickets D1 table maps event_id to ticket_id). + """Log resolution verdicts. Ticket resolution is not yet implemented. + + Architecture for when this is needed: + ---------------------------------------- + Closing tickets requires mapping Nostr event IDs to Zendesk ticket IDs. + The relay-manager solves this with a `zendesk_tickets` D1 table + (event_id -> ticket_id). Osprey runs in GKE, not CF Workers, so the + equivalent is a Postgres table in the osprey DB: + + CREATE TABLE zendesk_tickets ( + event_id TEXT PRIMARY KEY, + ticket_id INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() + ); + + Implementation steps: + 1. In `_create_ticket`: after a successful API call, INSERT into + zendesk_tickets (event_id from result, ticket_id from response). + 2. In `_log_resolution`: SELECT ticket_id WHERE event_id = , + then PATCH /api/v2/tickets/{ticket_id}.json with status='solved'. + 3. Inject the DB connection via __init__ (same pattern as + PostgresLabelsService in labels_service.py). + + For now, log and continue. Tickets accumulate but cause no operational + harm -- moderators can close them manually. """ action_name = result.action.action_name if result.action else 'unknown' logger.info(f'Resolution verdict: {verdict} action={action_name} (ticket resolution not yet implemented)') From 228f04c9bca454852a98721d9baf99d6fb6270d1 Mon Sep 17 00:00:00 2001 From: Matthew Bradley <168114+mbradley@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:11:15 -0400 Subject: [PATCH 3/4] fix: keep rule name ModerationServiceBan to match ClickHouse schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Osprey rule names become columns in the osprey.osprey_events ClickHouse table. Renaming the rule to ModerationServiceFlag without a corresponding ALTER TABLE made every worker flush fail with "Unrecognized column 'ModerationServiceFlag'", blocking ALL event ingestion (not just kind 1984). Keep the rule name ModerationServiceBan for now — the column already exists and output sink writes succeed. Signal-only semantics (flag_for_review verdict, no BanNostrEvent) are preserved. Follow-up: land a paired column + rule rename via iac-coreconfig + osprey PRs once coordination is in place. Discovered during staging validation of this PR: divine/staging-iterate deploy at 2026-04-17 16:06 UTC, rolled back at 16:52 after ~45 min of failed ClickHouse flushes. --- divine/rules/rules/reports/moderation_service.sml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/divine/rules/rules/reports/moderation_service.sml b/divine/rules/rules/reports/moderation_service.sml index a2878c2d..1a8e50bd 100644 --- a/divine/rules/rules/reports/moderation_service.sml +++ b/divine/rules/rules/reports/moderation_service.sml @@ -19,6 +19,13 @@ # This path is one of two for moderation-service output into Osprey: # - Kind 1984 (this file): automated AI signal, routes to human review # - Kind 1985 (content/label_routing.sml): human-verified decisions, enforces +# +# Naming note: the rule is still called `ModerationServiceBan` to match the +# existing `ModerationServiceBan` column in the `osprey.osprey_events` +# ClickHouse schema. Rule names become ClickHouse columns, so renaming the +# rule without a coordinated ALTER TABLE breaks every output sink flush. +# The semantics have changed (signal-only, no ban) but the name stays until +# we land a paired iac-coreconfig column rename. Import( rules=[ @@ -27,17 +34,17 @@ Import( ] ) -ModerationServiceFlag = Rule( +ModerationServiceBan = Rule( when_all=[ Kind == 1984, HasLabel(entity=Pubkey, label='moderation_service'), ReportReason in ['nudity', 'violence', 'ai_generated'], ], - description='Divine moderation service flagged content for human review', + description='Divine moderation service flagged content for human review (signal only, name retained for ClickHouse schema compatibility)', ) WhenRules( - rules_any=[ModerationServiceFlag], + rules_any=[ModerationServiceBan], then=[ LabelAdd(entity=EventId, label='ai_classified'), DeclareVerdict(verdict='flag_for_review'), From acc31c74b5aee4883158c4ab01def2f03bb98f83 Mon Sep 17 00:00:00 2001 From: Matthew Bradley <168114+mbradley@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:01:27 -0400 Subject: [PATCH 4/4] docs: add violence and ai_generated to canonical values list Addresses review nit from dcadenas on #4. The normalization changes in this PR introduced 'violence' and 'ai_generated' as new canonical outputs of _normalize_report_reason, but the inline comments in main.py and auto_hide.sml still listed the pre-change canonical set. Update both so the next reader has the full picture. --- divine/nostr-kafka-bridge/main.py | 3 ++- divine/rules/rules/reports/auto_hide.sml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/divine/nostr-kafka-bridge/main.py b/divine/nostr-kafka-bridge/main.py index 2fdb927e..80bc10f4 100644 --- a/divine/nostr-kafka-bridge/main.py +++ b/divine/nostr-kafka-bridge/main.py @@ -30,7 +30,8 @@ # Divine clients use different reason vocabularies. Normalize to canonical # values that SML rules can match consistently. # -# Canonical values: csam, nudity, spam, impersonation, illegal, harassment, other +# Canonical values: csam, nudity, violence, ai_generated, spam, impersonation, +# illegal, harassment, other # Mobile maps csam -> 'illegal' and sexual content -> 'nudity' per NIP-56. # Web passes raw reasons (csam, harassment, sexual-content, etc.). _REASON_ALIASES = { diff --git a/divine/rules/rules/reports/auto_hide.sml b/divine/rules/rules/reports/auto_hide.sml index a6a6fa29..53583495 100644 --- a/divine/rules/rules/reports/auto_hide.sml +++ b/divine/rules/rules/reports/auto_hide.sml @@ -2,7 +2,8 @@ # Automatically acts on reports from trusted reporters for CSAM and NSFW content. # # Report reasons are normalized by the bridge. Canonical values: -# csam, nudity, spam, impersonation, illegal, harassment, other +# csam, nudity, violence, ai_generated, spam, impersonation, illegal, +# harassment, other # # Mobile sends 'illegal' for CSAM (NIP-56 mapping), which the bridge # can't distinguish from violence/copyright 'illegal'. We match both