Technical writing

How Voidly detects when a censorship incident has ended: measurement decay, RESOLVED state transitions, and the re-open window

· 8 min read· AI Analytics
CensorshipVoidlyMethodologyInfrastructure

Detecting that a censorship incident has started is the easier half of the problem. Detecting that it has ended is harder — and getting it wrong in either direction has real consequences. A premature RESOLVED transition followed by a new block of the same domain creates a second incident where there should only be one, fragmenting duration statistics and confusing downstream consumers who track censorship timelines. A missed transition means the incident stays in ACTIVE or CORROBORATED state indefinitely, overstating the duration of a temporary block. This post covers Voidly's incident resolution pipeline: how passing measurements accumulate into a resolution signal, the re-open window that handles temporary lifts, and the edge cases that required special logic.

The resolution trigger: consecutive passing measurements

An incident transitions to RESOLVED when a domain shows a sufficient run of passing measurements — measurements where the classifier assignsp_blocked < 0.3 — from the same vantage country, without interruption by anomalous measurements. The required run length is tuned per interference type:

RESOLUTION_THRESHOLDS = {
    'dns_tamper': 4,       # 4 consecutive passing measurements (~20 minutes at 5-min cadence)
    'http_blocking': 4,    # same
    'tls_interference': 3, # TLS blocks resolve more cleanly; fewer confirmations needed
    'throttling': 6,       # throttling can be intermittent; require longer confirmation
    'bgp_withdrawal': 1,   # BGP re-announcement is unambiguous; one passing is sufficient
}

def check_resolution(
    incident_id: str,
    recent_measurements: list[Measurement],
    interference_type: str,
) -> bool:
    """
    Returns True if enough consecutive passing measurements warrant RESOLVED.
    recent_measurements must be sorted oldest-first.
    """
    threshold = RESOLUTION_THRESHOLDS[interference_type]
    consecutive_passing = 0
    for m in reversed(recent_measurements):  # newest first
        if m.p_blocked < 0.3:
            consecutive_passing += 1
            if consecutive_passing >= threshold:
                return True
        else:
            break  # any anomalous measurement resets the counter
    return False

The asymmetry in thresholds reflects the reliability of each signal type under resolution conditions. DNS tampering is highly consistent when active — every probe gets the same poisoned response — but inconsistent during resolution, when different ISPs may lift their blocks at different rates. Four consecutive passing measurements (roughly 20 minutes at a 5-minute probe cadence) is long enough to confirm that the block has been lifted across the dominant ISPs for that country. Throttling is the opposite: throttled connections intermittently pass quality checks even during an active throttling campaign, so a longer confirmation window (6 consecutive) is needed to avoid premature resolution.

BGP withdrawal resolution is handled differently because BGP re-announcement is a discrete, observable routing event rather than a gradual measurement signal. When IODA reports that previously withdrawn prefixes have been re-announced globally, that single event transitions the incident to RESOLVED without waiting for application-layer probe confirmation. The opposite is also true: a BGP incident does not require confirmation from HTTP or DNS probes to open, and does not require HTTP/DNS evidence to close.

The 12-hour re-open window

Governments sometimes lift a block temporarily — for example, unblocking Twitter for the duration of an election to create plausible deniability, then re-blocking it within hours of the results being announced. If Voidly closes an incident when the block is temporarily lifted and then opens a new incident when the block resumes, the result is two incidents that should be one, with the duration split across both.

The 12-hour re-open window prevents this fragmentation. When an incident is resolved, it enters a RESOLVED_PENDING state for 12 hours. During this window, if a new anomalous measurement matching the same four-tuple (country, domain, interference_type, probe_type_group) appears, the original incident is reopened to ACTIVE rather than creating a new incident:

def handle_new_anomaly(
    domain: str,
    country: str,
    interference_type: str,
    probe_type_group: str,
    timestamp: datetime,
    db: Database,
) -> str:
    """
    Returns the incident_id to assign this measurement to.
    Creates a new incident or reopens a recently-resolved one.
    """
    # Check for a recently-resolved incident (within 12h)
    reopen_window = timedelta(hours=12)
    recent_resolved = db.query('''
        SELECT incident_id, resolved_at
        FROM incidents
        WHERE domain = ? AND country_code = ?
          AND interference_type = ? AND probe_type_group = ?
          AND status = 'RESOLVED_PENDING'
          AND resolved_at > ?
        ORDER BY resolved_at DESC
        LIMIT 1
    ''', domain, country, interference_type, probe_type_group,
        timestamp - reopen_window)

    if recent_resolved:
        incident_id = recent_resolved['incident_id']
        db.execute(
            'UPDATE incidents SET status = ? WHERE incident_id = ?',
            'ACTIVE', incident_id
        )
        db.execute(
            'INSERT INTO incident_events (incident_id, event_type, ts) VALUES (?,?,?)',
            incident_id, 'REOPENED', timestamp
        )
        return incident_id

    # No recent resolved incident; open a new one
    return create_new_incident(domain, country, interference_type, probe_type_group, timestamp, db)

The 12-hour threshold was calibrated on 400 ground-truth incidents from the historical dataset. We evaluated thresholds from 6 hours to 48 hours: at 6 hours, some legitimate temporary lifts (planned maintenance windows, ISP outages that masked the original block) were incorrectly merged into the same incident. At 48 hours, too many genuinely separate events were merged. At 12 hours, the merge error rate was 3.2% (too many merges) compared with 7.8% at 6 hours and 11.4% at 48 hours.

FLAPPING state for oscillating blocks

Some censorship implementations — particularly those driven by transparent proxies or TSPU hardware under high load — produce measurements that alternate between blocked and accessible within the same hour. The probe gets blocked on one request, then the next request (5 minutes later) passes because the DPI hardware is temporarily overloaded and lets packets through. This is genuine censorship, but the alternating signal pattern would continuously trigger and falsely resolve the incident if we relied solely on the consecutive passing measurement threshold.

class IncidentStateMachine:
    def update(self, incident: Incident, new_measurements: list[Measurement]) -> None:
        # Count transitions in the last 2-hour window
        recent = [m for m in new_measurements if m.hours_ago <= 2]
        transitions = sum(
            1 for a, b in zip(recent[:-1], recent[1:])
            if (a.p_blocked >= 0.5) != (b.p_blocked >= 0.5)
        )

        if transitions >= 4:
            # 4+ alternations in 2 hours = FLAPPING
            incident.status = 'FLAPPING'
            incident.flapping_since = datetime.utcnow()
            # FLAPPING incidents cannot resolve via normal measurement decay;
            # require manual review or BGP resolution signal
            return

        if incident.status == 'FLAPPING':
            # Check if flapping has settled (no transitions for 90 minutes)
            if incident.flapping_since < datetime.utcnow() - timedelta(minutes=90):
                incident.status = 'ACTIVE'
                # Re-enter normal resolution check on the next measurement batch

        # Normal resolution check
        if check_resolution(incident.incident_id, new_measurements, incident.interference_type):
            incident.status = 'RESOLVED_PENDING'
            incident.resolved_at = datetime.utcnow()

FLAPPING incidents are surfaced to consumers via theincident_status field in the API response. Consumers who need to track duration should treat FLAPPING incidents with caution — the duration may overcount the actual period of impact if the implementation intermittently passes traffic. FLAPPING incidents appear in the public dataset withconfidence_tier = CORROBORATED rather than VERIFIED, capping them below the threshold used by most journalist and policy workflows.

Cross-source resolution requirements

For incidents that reached the VERIFIED tier (confirmed by OONI or CensoredPlanet corroboration), resolution also requires that the corroborating sources show no ongoing blocking. A Voidly probe measurement passing while the OONI feed still shows active blocking for the same domain in the same country is not a resolution — it is more likely that the affected ISP in the Voidly probe country has lifted the block while other ISPs still enforce it.

def check_cross_source_resolution(
    incident_id: str,
    ooni_recent: list[OoniMeasurement],
    censoredplanet_recent: list[CpMeasurement],
    ioda_recent: list[IodaSignal],
) -> bool:
    """
    For VERIFIED incidents, all corroborating sources must agree before resolution.
    """
    if ooni_recent and mean_ooni_blocking(ooni_recent) > 0.25:
        return False  # OONI still seeing active blocking
    if censoredplanet_recent and mean_cp_blocking(censoredplanet_recent) > 0.25:
        return False  # CensoredPlanet still seeing active blocking
    # IODA check only applies to BGP-type incidents
    return True

Resolution time distributions

Across 1,574 verified incidents in the dataset, median time from first detection to RESOLVED transition:

Interference typeMedian durationp90 durationNotes
BGP withdrawal4.2 hours18.7 hoursIncludes election/protest shutdowns (short) and infrastructure failures (long)
DNS tampering8.4 days> 90 daysDNS blocks rarely lifted; median skewed by many long-running blocks
HTTP blocking12.1 days> 90 daysSimilar distribution to DNS; court-ordered blocks longest
TLS interference3.1 days18.2 daysOften temporary during political events; resolves more quickly
Throttling6.2 hours3.8 daysOften during peak hours only; resolves naturally but may recur

The bimodal nature of BGP incident duration — short shutdowns (election-day blocks, protest suppression) and long infrastructure failures — means the median (4.2 hours) underrepresents both modes. The short mode peaks around 2–6 hours; the long mode peaks around 24–72 hours. DNS and HTTP incidents have the heaviest right tail: the p90 exceeds 90 days because court-ordered website blocks in Turkey, Russia, and Indonesia are effectively permanent, and Voidly has open incidents tracking them continuously.

The nightly retroactive pass

CensoredPlanet's data arrives as a nightly batch export rather than a real-time feed. When the batch arrives, Voidly re-evaluates the resolution status of any incident that transitioned to RESOLVED in the prior 48 hours. If CensoredPlanet's data shows blocking continued past the Voidly-observed resolution time, the incident'sresolved_at timestamp is updated to reflect the CensoredPlanet evidence and the incident is reopened until the retrospective data also shows clean measurements. This retroactive adjustment is logged as aRESOLUTION_REVISED event in the incident_events table and is visible in the API response's revision_history field.


For how incidents are first created — the four-tuple clustering key and the 6-hour gap rule: Incident clustering and deduplication: how Voidly avoids counting the same censorship event twice →

For how new anomalies are detected and pushed into the incident pipeline within 8 minutes: Voidly's real-time event pipeline: from measurement anomaly to journalist alert in under 8 minutes →

For how cross-source corroboration from OONI and CensoredPlanet is incorporated into incident confidence: Cross-source censorship verification: reconciling OONI, CensoredPlanet, and IODA →

For the incident_status and resolution fields in the published measurement dataset: The Voidly measurement dataset: field-by-field schema reference →

For how the full incident event sequence is reconstructed from distributed probe measurements — IncidentEvent sourcing, temporal alignment, and retroactive revision: Voidly incident timeline reconstruction: building the canonical event sequence from distributed probe measurements →