Internet shutdowns are easy to detect. The harder question is why they happened — was it the election? the protest? a court ruling? a policy update? Until today, Voidly could surface the WHAT (incident detected, severity X, mechanism Y) and the WHERE (country, ASN, blocked domains), but the WHY was guesswork.

Today we shipped synthetic difference-in-differences attribution at /sentinel/attribute{' '} and /v1/sentinel/attribute. For any country + date pair, we compute a counterfactual block-rate from weighted stable-democracy donors, measure the post-period gap, and run a permutation p-value to gauge significance.

How it works

  1. Pull daily block_rate for the treated country, T−14 through T+6, from evidence.
  2. Pull same window for 15 candidate donors (stable democracies: US, JP, DE, GB, FR, AU, CA, NL, SE, CH, FI, NO, DK, IE, NZ).
  3. Filter to donors with ≥5 observations per period.
  4. Fit synthetic-control weights via scipy SLSQP with constraints w ≥ 0, Σw = 1. Minimize sum-of-squares against treated's pre-period.
  5. Post-period: causal_effect = treated_post_mean − synthetic_post_mean.
  6. In-space placebo permutation for p-value.
  7. Cross-reference events.db for nearby political events (election/protest/coup/policy/religious).

Why this is novel

The synthetic difference-in-differences technique was developed for economic policy evaluation. Internet Society's NetLoss paper (ACM JCSS 2024) applied a similar method to estimate the GDP impact of internet shutdowns. But nobody publishes the censorship-attribution analog at scale — given a shutdown, attribute it to a triggering event with a defensible counterfactual.

Most prior work treats causality as “the protest happened and then the shutdown happened” correlation. SDiD lets us say something stronger: given comparable peer countries had no shutdown, this country's spike is X percentage points above expected, with p-value Y.

Test case: Iran 2026-05-13

MetricValue
Donor weightsNL 0.95, CA 0.04, AU 0.01
Pre-period RMSE0.036 (good fit, well under 0.10 ceiling)
Treated post-mean92.6%
Synthetic post-mean95.2%
Causal effect−2.6 pp
Permutation p-value0.25 (not significant)
Nearby eventOONI anomaly signal 2026-05-06 (upstream_anomaly_signal)
low_confidencefalse

The negative causal effect is mechanically correct but tells an interesting story: Iran was already at ceiling (96% block_rate) in the pre-period, so there was no “room above” for the trigger to lift things further. SDiD correctly reports the small negative number + non-significant p-value. The script doesn't editorialize — it lets the data speak.

This honest behavior is the point. A naive attribution would have said “Iran had 92% block rate, the OONI anomaly fired, attribution: anomaly.” SDiD says “synthetic Iran was already at 95%, so the anomaly didn't add anything measurable.” Big difference.

When attribution is meaningful

The endpoint reports low_confidence: true with a reason when these conditions aren't met. Don't cite the causal effect in those cases.

Honest caveats

What this enables

Reproducibility

Implementation in scripts/sdid_attribution.py (312 lines). Endpoint wrapper in scripts/patch-sdid-endpoint.py. Both in the public repo. Dependencies: numpy + scipy.optimize.minimize (SLSQP). The donor pool, p-value parameters, and confidence thresholds are constants at the top of the script — easy to audit + fork.