# Voidly Probe Protocol

Custom-client ingest-key protocol. The published Python and Docker clients use
the separate community-node registration path and token. Reading this guide
does not register a node; running its POST examples does. Review the target
list and [participation risks](https://voidly.ai/probes#risks) first.

---

## Why run a probe

Each measurement can add a vantage point to Voidly's censorship record.
Registration alone is not a measurement or proof of downstream acceptance.
The Worker has a quality evaluation that can promote a healthy `trial` probe
to `full` after at least 7 days and raise its rate limit; a registration
is also visible in the public heartbeat feed on
[voidly.ai/probes](https://voidly.ai/probes).

The registration table stores your chosen public node ID, optional country and
ASN, software version and a hash of the ingest key. The API and network
providers can observe the source IP of your request. Do not treat the public
node ID, location and timestamped contact or measurements as anonymous.

---

## Quickstart

### 1. Register a custom client

The command below creates a public registration if you run it. Replace the
example ID and location with values you are willing to publish. Do not run
the example to test this guide.

```bash
curl -X POST https://api.voidly.ai/v1/probe/register \
  -H "Content-Type: application/json" \
  -d '{
    "node_id": "my-probe-de-1",
    "version": "1.0.0",
    "geo": { "country": "DE", "asn": "AS24940" },
    "domains_supported": ["x.com", "wikipedia.org"]
  }'
```

Response:

```json
{
  "ok": true,
  "node_id": "my-probe-de-1",
  "ingest_key": "<save this — never shown again>",
  "ingest_key_header": "X-Probe-Ingest-Key",
  "status": "trial",
  "trial_ends_at": 1715184000,
  "promote_threshold": 0.9,
  "rate_limit_per_min": 100,
  "submit_endpoint": "https://api.voidly.ai/v1/probe/results",
  "heartbeat_interval_s": 300
}
```

**Save the `ingest_key`.** It's hashed at rest and cannot be recovered. If
you lose it, register a fresh `node_id` — duplicates of an existing
`node_id` are idempotent and won't issue a new key.

### 2. Submit results

POST your probe results to `submit_endpoint` with the key in the
`X-Probe-Ingest-Key` header:

```bash
curl -X POST https://api.voidly.ai/v1/probe/results \
  -H "Content-Type: application/json" \
  -H "X-Probe-Ingest-Key: <your-key>" \
  -d '{
    "nodeId": "my-probe-de-1",
    "country": "DE",
    "results": [
      { "domain": "x.com", "blocked": false, "confidence": 0.98, "latencyMs": 142 }
    ]
  }'
```

The Worker records `last_seen_at` on an authenticated submission before
downstream forwarding. This contact signal is not a receipt that the
measurement was accepted by the downstream service. Stop submitting
for >24h and you'll be auto-suspended. Stop for >14d and you're deactivated.

### 3. Stay healthy

The registration response currently specifies a 5-minute heartbeat interval.
The public heartbeat feed marks a row quiet after 10 minutes without contact.
That is a contact label, not proof of accepted measurements.

---

## Required fields

### `POST /v1/probe/register`

| Field | Required | Description |
|-------|----------|-------------|
| `node_id` | yes | Stable 4–64 char identifier you control. Letters, digits, `._-/` only. |
| `version` | optional | Probe software version (≤ 32 chars). |
| `geo.country` | optional | ISO 3166-1 alpha-2; published as regional metadata. |
| `geo.asn` | optional | `AS12345` or `12345`. Used for ISP-level analysis only. |
| `domains_supported` | optional | Informational. It does not control the approved-domain set. |

### `POST /v1/probe/results`

Same shape as the existing community probe payload. `results` is an array of
domain checks. The registration response includes the static baseline list,
not every accepted target. Ingest also checks the current
[served domain registry](https://api.voidly.ai/v1/probe/domains). Off-list
domains can be dropped; inspect the response and served registry before
submitting.

---

## Quality scoring

Once an hour the Worker evaluates every registered probe:

```
quality = success_rate × 0.7  +  freshness × 0.3

success_rate = (total - failed) / max(1, total)
freshness    = 1.0 if seen ≤ 1h ago,
               linearly decaying to 0 at 24h
```

- **Promote** (`trial → full`): quality ≥ 0.9 AND registration age ≥ 7 days
- **Suspend** (`full → suspended`): quality < 0.5 OR silent > 24h
- **Suspend** (`trial → suspended`): silent > 24h
- **Deactivate** (`suspended → deactivated`): silent > 14d

The checked-in evaluator has no automatic suspended-to-full recovery branch.
Do not infer recovery or accepted measurements from a heartbeat or tier label.

---

## Rate limits

| Tier | Limit |
|------|-------|
| `trial` | 100 result submissions / minute |
| `full` | 600 result submissions / minute |

If you exceed the cap you get `429 Rate limit exceeded` with a
`Retry-After: 60` header. Over a sustained tier excess, your `failed_probes`
counter grows and your quality score drops.

---

## Privacy

- **Source IP visibility.** The registration table stores optional ASN and country, not a raw IP field. The API, network operators and service providers can still observe the source IP of a request.
- **No PII.** Don't include user identifiers, email addresses, or session
  tokens in your `nodeId` or any submission.
- **Public node_id.** The `node_id` is visible via
  `GET /v1/probe/network/heartbeats`. Pick one that doesn't expose internal
  hostnames you'd rather not publish.

---

## Self-healing

Voidly can operate and repair its own core nodes. This public protocol does not
authorize Voidly to control a participant's machine. If your own client stops,
inspect it and the public heartbeat response yourself.

## Public activity labels

The public heartbeat feed and community node directory show a display-only
`activity_status` for each returned node. It is `active` when the last valid
contact is at most 10 minutes old, `quiet` after 10 minutes and before 30
days, and `retired` at 30 days or older. A missing, invalid, or future
last-seen time is `unknown`. The heartbeat response includes
`activity_counts` for all returned rows and `activity_policy` with the exact
thresholds. These labels do not change the stored registration `status`,
remove node records, or affect probe tier decisions. The existing `is_silent`
flag still uses the 10-minute contact threshold. An authenticated submission
can advance contact before forwarding; a community token validation can also
advance its registry contact and counter without a measurement.

---

## Endpoints reference

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/v1/probe/register` | none | Self-onboard a new probe |
| `POST` | `/v1/probe/results` | `X-Probe-Ingest-Key` | Submit measurements |
| `GET`  | `/v1/probe/network/heartbeats` | none | Live liveness feed |
| `GET`  | `/v1/probe/priority-targets` | none | Domains × countries we want covered |
| `GET`  | `/v1/probe/notifications` | none | Public network announcements |

---

## Versioning

This protocol is `v1`. Breaking changes will be versioned at the path level
(`/v2/probe/...`); existing v1 probes will continue to function until at
least 90 days after a v2 promotion to general availability.
