The gap the watchdog plugs

Voidly creates incidents through three deterministic pipelines: scripts/create-voidly-incidents.py (Voidly probe blocks), scripts/ingest-ooni-incidents.py (OONI ground-truth signals), and the IODA ingest's promote_to_incidents() function (which produces noisy disruption labels, not censorship). Each of these is a thin rule over one upstream source. None of them looks at the joint signal across sources, and none of them looks at our second-opinion DBSCAN unsupervised anomaly model. If a country-day looks like censorship but the three rules miss it, the only way it makes it into the incident table is if a human notices.

What the watchdog does

scripts/auto-incident-watchdog.py runs every six hours under cron and proposes draft incidents only when four independent gates fire together:

  1. The DBSCAN second-opinion model flagged the country-day as anomalous (is_anomaly = true on the leaderboard).
  2. The Bayesian corroboration model puts the per-day censorship posterior at or above 0.5.
  3. No non-draft incident exists for the country within +/- 7 days (so we don't duplicate work the deterministic pipelines already did).
  4. The country-day has at least one anomalous-level evidence row from a confirmed source — OONI, CensoredPlanet, or a Voidly probe. IODA disruption rows are deliberately excluded from this gate, for the same reason the v3.3 classifier's training labels exclude them: IODA is a connectivity-disruption signal, not a censorship signal, and treating it as ground truth produces noise.

When all four hold, the watchdog writes a JSON draft to /opt/voidly-ai/data/auto_incidents_queue/<slug>.json with status = "draft", created_by = "auto-watchdog", the Bayesian posterior under evidence_corroboration_posterior, the DBSCAN anomaly score, the IDs of every linked confirmed-source evidence row, and a template narrative. The drafts are exposed at GET /v1/atlas/auto-incidents-pending for an editor to review.

What the watchdog explicitly does not do

First-run result (2026-05-21 18:02 UTC)

Four DBSCAN flips on the leaderboard: MA, EG, IQ, IN on 2026-05-20. All four failed the Bayesian corroboration gate (posteriors 0.37, 0.37, 0.00, 0.18 — well below the 0.5 floor). All four also have existing incidents within +/- 7 days, so even with a high posterior they would have been deduped. Net: 0 drafts written. That is the honest baseline — on a normal day, the deterministic pipelines cover the obvious cases, and the watchdog stays quiet.

Operations

Editor flow

Every draft is a single JSON object with a stable slug, the trigger numbers, and the linked evidence row IDs. To promote a draft, an editor confirms the underlying numbers (each draft's raw_links field points at the live API endpoints the trigger was computed from), then inserts a real row into the incidents table by copying the draft's fields into one of the existing creator scripts (create-voidly-incidents.py or ingest-ooni-incidents.py) with status = "active". To dismiss a draft, the editor deletes the JSON — no database rollback is needed because the watchdog never touched the database.

Honest caveats