# Living Signals REST API v1

The Living Signals REST API is read-only and rooted at `/api/v1`. Start with
`GET /api/v1/countries`: it tells you which countries the key can access and
the coverage window currently available for each one. `GET /api/v1` returns a
compact machine-readable discovery document.

## Authentication and entitlements

Send an API key in either header:

```http
Authorization: Bearer ls_cust_<token>
```

```http
X-API-Key: ls_cust_<token>
```

Keys can be created and managed in the Living Signals app at `/access#api`.

Customer keys are restricted to their active country grants. When a country
filter is omitted, the API automatically scopes the request to all countries
the customer is entitled to. Explicitly naming any unentitled country rejects
the whole request with `403 country_not_entitled`; the API never returns a
partial subset. Internal team keys (`ls_live_...`) are not country-restricted.

Annotation-layer grants are separate entitlements. Customer responses include
only granted layers. The key's granted layers are listed machine-readably by
`GET /api/v1/countries` in `annotation_layers`. Internal team keys receive the
core response by default and can request stored annotations where documented.

Authentication and entitlement failures use distinct machine-readable codes:

| HTTP | Code | Meaning |
| --- | --- | --- |
| 401 | `missing_credentials` | Neither a Bearer token nor `X-API-Key` was supplied. |
| 401 | `invalid_credentials` | The supplied key is not recognized. |
| 403 | `key_revoked` | The key has been revoked. |
| 403 | `key_expired` | The key has expired. |
| 403 | `customer_inactive` | The customer account is not active. |
| 403 | `country_not_entitled` | At least one explicitly requested country is outside the key's grants. |

## Conventions

### Dates and timestamps

All timestamps are RFC 3339 UTC strings, for example
`2026-07-20T21:31:21.628Z`.

`event_date` is different: it is a bare `YYYY-MM-DD` content date. It can be
`null`, and it can be imprecise or unreliable because it is extracted by an
LLM from source content. Use `extracted_at` and the `since_extracted` parameter
for incremental polling.

Where a `since`-style parameter is available, it accepts only `<n>h` (hours),
`<n>d` (days), or `<n>w` (weeks) as a relative window, or an ISO
`YYYY-MM-DD[Thh:mm:ssZ]`-shaped date/timestamp. The time part is optional.
Other suffixes and date-like strings, including `7z`, `2020z`, `7m`, and `7y`,
return `400 invalid_parameter`.

### Strict validation and errors

Query-parameter handling has three precise tiers. A malformed value of a
parameter supported by the endpoint returns `400 invalid_parameter`. A
parameter the API recognizes but the endpoint does not support also returns
`400 invalid_parameter`. An unrecognized parameter name is ignored, consistent
with standard HTTP query handling. Supported values are not silently coerced
into a different query. One exception is an integer `limit` above the
endpoint's maximum: it is clamped to the maximum, and the effective value is
echoed in `pagination.limit`.

On an endpoint that supports cursor pagination, an omitted cursor starts a page
walk. An explicitly empty `cursor=` is not the same as omission and returns
`400 invalid_cursor`.

Errors have one envelope:

```json
{
  "error": {
    "code": "invalid_parameter",
    "message": "`level` must be 0 (Signals) or 1 (Topics); got \"2\"."
  }
}
```

Common non-authentication errors are:

| HTTP | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_parameter` | A supported parameter value is malformed, or an API-recognized parameter is unsupported by this endpoint. |
| 400 | `invalid_cursor` | A cursor is malformed or is not valid for the endpoint's sort key. |
| 404 | `not_found` | The API path or requested resource does not exist; unknown API paths also return this JSON error. |
| 405 | `method_not_allowed` | The path exists, but the HTTP method is unsupported. Check the `Allow` response header. |
| 429 | `rate_limit_exceeded` | The customer-key request limit was exceeded. Check `Retry-After`. |

Every unknown path under `/api/*`, including one outside `/api/v1/*`, returns
the JSON `404 not_found` envelope rather than an HTML framework response.

### Rate limits

Customer keys are currently limited to 240 requests per minute per key. Once
an authenticated customer-key request reaches the rate limiter, every response
issued after that point carries these headers. That includes successful
responses, `429` responses, and `4xx` validation or entitlement/scope errors.

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed in the current window. |
| `X-RateLimit-Remaining` | Requests remaining in the current window. |
| `X-RateLimit-Reset` | Seconds remaining until the current window resets; this is a relative duration, not a Unix epoch timestamp. |

A `429 rate_limit_exceeded` response also includes `Retry-After` in seconds.
The `X-RateLimit-*` headers are absent from `401` responses because
authentication runs before the limiter, and from the unauthenticated JSON
`404` catch-all. Internal team keys are not subject to this customer-key limit
and therefore do not carry these headers.

## `GET /api/v1/countries`

Call this first to discover your entitlements and coverage. It accepts no
query parameters and returns rows ordered by `code` ascending. Supplying any
API-recognized/reserved parameter returns `400 invalid_parameter`; unrelated,
unrecognized parameter names are ignored as described above.

The top-level `annotation_layers` array is the machine-readable list of
standard-taxonomy layers granted to the key. It can be empty. Customer keys see
their granted layers; internal team keys see every stored layer.

Use the list together with each Signal's `annotations` object:

| Layer in `annotation_layers`? | Signal value | Meaning |
| --- | --- | --- |
| Yes | Layer key with non-empty `codes` | Graded and stored with the returned applicable codes. |
| Yes | Layer key with `codes: []` | Graded and stored, but not applicable. |
| Yes | Layer key absent | Grading is not yet stored; treat it as pending. |
| No | Layer key absent | The key is not entitled to that layer. |

For a customer key, the response contains every granted country code. A grant
is authoritative even if its code does not yet have a catalog row: that code
still appears with `name: null`, `status: null`, and `source_count: 0`. For an
internal team key, the response contains every row in the country catalog.

| Field | Meaning |
| --- | --- |
| `code` | Granted/cataloged ISO 3166-1 alpha-2 country code. |
| `name` | Catalog name, or `null` when a granted code has no catalog row. |
| `status` | `live`, `in_curation`, `pending`, or `archived`; `null` when the catalog row is absent. |
| `claim_count` | Total retrievable claims for the country (`extraction_status = 'extracted'`). |
| `source_count` | Computed live from active source-country memberships, or `0` when there are none. |
| `first_claim_at` | Earliest retrievable-claim ingestion timestamp, or `null` when there are no retrievable claims. |
| `last_claim_at` | Latest retrievable-claim ingestion timestamp, or `null` when there are no retrievable claims. |

Coverage is scoped to the [retrievable corpus](#retrievable-corpus-and-clustering-lag).
Internally rejected or failed extraction outcomes do not contribute to these
figures. For each country, `claim_count` equals the number of rows returned by
a full country-scoped `/claims` walk, and `last_claim_at` is always the
timestamp of a claim reachable through `/claims`.

Example:

```json
{
  "annotation_layers": ["acled_v1", "glide_v1", "iasc_v1"],
  "countries": [
    {
      "code": "SD",
      "name": "Sudan",
      "status": "live",
      "claim_count": 18420,
      "source_count": 37,
      "first_claim_at": "2026-05-02T06:14:20.931Z",
      "last_claim_at": "2026-07-20T21:26:42.133Z"
    },
    {
      "code": "XX",
      "name": null,
      "status": null,
      "claim_count": 0,
      "source_count": 0,
      "first_claim_at": null,
      "last_claim_at": null
    }
  ]
}
```

Successful responses use `Cache-Control: private, max-age=300`.

## `GET /api/v1/claims`

Returns extracted statements with their article, source, geography, and Signal
and Topic provenance. Results are paginated by claim `id` ascending, a stable
keyset order that approximately follows ingestion order.

### Parameters

| Parameter | Meaning |
| --- | --- |
| `country` | ISO-2 country filter. Repeat it (`country=SD&country=VE`) or use CSV (`country=SD,VE`). Matching is case-insensitive; omit it to use the key's full scope. |
| `cluster` | Signal or Topic `stable_id`. It must be a UUID; non-UUID input returns `400 invalid_parameter`. A Topic id includes claims assigned through its child Signals. |
| `since` | Activity-time floor applied to `COALESCE(event_date, extracted_at)`: content event date where known, otherwise ingestion time. Accepts only `<n>h` hours, `<n>d` days, `<n>w` weeks, or an ISO `YYYY-MM-DD[Thh:mm:ssZ]`-shaped date/timestamp. |
| `since_extracted` | Ingestion-time floor on `extracted_at`. This is the correct incremental-polling cursor. Accepts only `<n>h` hours, `<n>d` days, `<n>w` weeks, or an ISO `YYYY-MM-DD[Thh:mm:ssZ]`-shaped date/timestamp. |
| `limit` | Page size; default 50, maximum 500. Values above 500 clamp to 500. |
| `cursor` | Opaque endpoint-specific keyset cursor. Prefer following `nextPage` instead of constructing it. An explicitly empty value returns `400 invalid_cursor`. |
| `offset` | Non-negative legacy offset, default 0. Ignored when `cursor` is present. |

`since_extracted` exists only on `/claims` because `extracted_at` is the claim
ingestion watermark. `/clusters` and `/sources` recognize the parameter name
but do not expose that claim-level filter, so either endpoint returns
`400 invalid_parameter` when it is supplied; use cluster `since` for a
`last_claim_at` floor.

### Response

```json
{
  "claims": [
    {
      "id": "5210",
      "claim": "Flooding displaced more than 1,200 people across three communities in White Nile State.",
      "event_date": "2026-07-19",
      "extracted_at": "2026-07-20T21:26:42.133Z",
      "country_code": "SD",
      "geo_scope": "country",
      "place_name": null,
      "place_country": "SD",
      "region_code": null,
      "lat": null,
      "lng": null,
      "signal_stable_id": "70d5a0c6-965f-4794-a691-110d66bf6b48",
      "topic_stable_id": "eacccb28-63e4-4a12-9486-cf5c58b0945d",
      "article": {
        "url": "https://news.example.org/sudan/flood-response",
        "title": "Communities seek shelter after White Nile flooding",
        "published_at": "2026-07-20T18:02:00.000Z"
      },
      "source": {
        "id": "a12e699c-cfa2-4ce9-8c01-bec72963ea91",
        "name": "Example News",
        "language": "en",
        "country_code": null
      }
    }
  ],
  "nextPage": "https://api.livingsignals.example/api/v1/claims?country=SD&limit=1&cursor=NTIxMA",
  "pagination": {
    "limit": 1,
    "offset": 0,
    "count": 1
  }
}
```

`event_date` follows the special bare-date semantics described above, and
`extracted_at` is an RFC 3339 timestamp. `article.published_at` is an RFC 3339
timestamp when the source feed supplies a publish date, and is otherwise
`null`. HTML entities in `article.title` are decoded before it is returned.

The country-ish fields in a claim row describe four different facts:

| Field | Meaning |
| --- | --- |
| `country_code` | The country whose pipeline extracted the claim. This is the entitlement axis, not necessarily the event location. |
| `place_country` | The country where the described event happened, when known. |
| `region_code` | The UN M49 region for a region-scope claim, when the region can be resolved; otherwise `null`. |
| `source.country_code` | The outlet's headquarters country. It is often `null` and can differ from both `country_code` and `place_country`. |

Country codes are matched case-insensitively and normalized to uppercase in
API responses.

`geo_scope` has three values:

| Value | Meaning |
| --- | --- |
| `point` | A place-level claim. Only point rows with non-null coordinates expose `lat` and `lng`. |
| `country` | A country-level claim. `place_country` identifies the country. |
| `region` | A regional claim. `region_code` identifies the UN M49 region when resolved, but can be `null` when the extractor recognizes a regional story that the geocoder cannot resolve to an M49 code. |

`lat` and `lng` are `null` unless `geo_scope` is `point`. Coarse country- and
region-level claims must not become misleading precise map pins.

### Retrievable corpus and clustering lag

The pipeline records extraction outcomes that fail QA internally with
`extraction_status = 'rejected'` (the project's [honest-rejection
principle](decisions/007-honest-rejection.md)) or
`extraction_status = 'failed'`. These outcomes are never served by any
endpoint. Only rows with
`extraction_status = 'extracted'` belong to the retrievable corpus, and
`/countries` excludes rejected and failed outcomes from `claim_count`,
`first_claim_at`, and `last_claim_at`. Consequently, a country's `claim_count`
matches a full country-scoped `/claims` walk, and its `last_claim_at` always
identifies a reachable claim.

Extraction and clustering are separate pipeline steps. A just-extracted claim
is immediately visible in `/claims` with `signal_stable_id` and
`topic_stable_id` still `null`; the next clustering run assigns those IDs.
Polling with `since_extracted` therefore never misses the claim, but cluster
IDs for the newest claims can lag by one pipeline cycle. Re-fetch the claim,
for example with `GET /api/v1/claims/{id}`, to pick up its assignments.

### Polling recipe

For incremental ingestion, record the poll instant immediately before making
the request and send it on the next poll as
`since_extracted=<last poll instant>`. Use a small overlap between windows and
deduplicate on claim `id`; this makes polling tolerant of boundary timing,
retries, and partially completed page walks.

If a response has a non-null `nextPage`, follow that URL unchanged before
advancing the poll instant. This drains every page with the same filters and
uses the server-issued cursor.

Do **not** use `event_date` or `since` as an incremental cursor. `event_date`
describes source content, may be old, null, or unreliable, and does not tell
you when Living Signals ingested the claim. Consequently, `since` is an
activity-analysis filter, not a polling watermark.

## `GET /api/v1/claims/{id}`

Returns one claim with the same row shape as an element of the `/claims` list,
wrapped as `{ "claim": ... }`. The path `id` must be numeric; a non-numeric
value returns `400 invalid_parameter`. An existing claim outside the key's
country grants returns `403 country_not_entitled`, while an id that does not
exist returns the JSON `404 not_found` envelope.

Use this lookup to refresh a just-extracted claim after clustering or to learn
its current `signal_stable_id` and `topic_stable_id` after cluster
reconciliation.

## `GET /api/v1/clusters`

Returns the active two-level emergent model: time-bounded Signals at level 0
and durable Topics at level 1. Rows are ordered by ascending internal row id,
a stable keyset order that is neither chronological nor guaranteed to be
meaningful. Use `since`, `last_claim_at`, and `formed_at` for recency.

### Parameters

| Parameter | Meaning |
| --- | --- |
| `country` | ISO-2 country filter, repeatable or CSV. Matching is case-insensitive; omit it to use the key's full scope. |
| `level` | `0` for Signals or `1` for Topics. Anything else returns `400 invalid_parameter`. When omitted, the analyst-default view contains Topics and Signals with a returned Topic shadowing its returned Signal children to avoid double-reporting. |
| `since` | Floor on `last_claim_at`. Accepts only `<n>h` hours, `<n>d` days, `<n>w` weeks, or an ISO `YYYY-MM-DD[Thh:mm:ssZ]`-shaped date/timestamp. |
| `limit` | Page size; default 50, maximum 500. Values above 500 clamp to 500. |
| `cursor` | Opaque endpoint-specific keyset cursor. Prefer the cursor in `nextPage`. An explicitly empty value returns `400 invalid_cursor`. |
| `offset` | Non-negative legacy offset, default 0. Ignored when `cursor` is present. |
| `include_annotations` | Internal team keys only: `true` includes every stored annotation layer; `false` leaves the default response unchanged. Only literal `true`, literal `false`, or omission is accepted; every other value returns `400 invalid_parameter`. Customer keys automatically receive only their granted layers. |

### Levels, tags, and annotations

- A level-0 **Signal** is a time-bounded cluster of claims. Its `topic` object
  identifies the durable parent Topic when one exists.

- A level-1 **Topic** is durable. The `topic` object on a level-1 row points to
  that same row: its `id` equals `stable_id` and its label is the Topic label.
  This self-reference is intentional and lets clients handle both levels with
  one shape.

- Topic-level `rubric_tags` and `categories` are the **UNION of their child
  Signal values**. Filter and facet on Signals (`level=0`), not Topics. A broad
  Topic spans time and can accumulate mutually absurd combinations that never
  co-occurred in any one Signal.

- `annotations` exist on level-0 Signals only. They are projections into
  independently attributed standard taxonomies and do not influence cluster
  formation. Each returned layer has `{ "codes": [...], "rationale": "..." }`.
  Discover the key's granted-layer list at `GET /api/v1/countries` in the
  top-level `annotation_layers` array.

Interpret Signal annotation state as follows:

| Layer in `annotation_layers`? | Signal value | Meaning |
| --- | --- | --- |
| Yes | Layer key with non-empty `codes` | Graded and stored with the returned applicable codes. |
| Yes | Layer key with `codes: []` | Graded and stored, but not applicable. |
| Yes | Layer key absent | Grading is not yet stored; treat it as pending. |
| No | Layer key absent | The key is not entitled to that layer. |

The core row fields are `stable_id`, `level`, `parent_stable_id`, `label`,
`description`, `country_code`, `claim_count`, `source_count`, `last_claim_at`,
`formed_at`, `topic`, `categories`, and `rubric_tags`. Entitled Signal rows may
also contain `annotations`. Both timestamp fields are RFC 3339 UTC.

### Cluster lifecycle and `stable_id`

Clusters are re-derived by pipeline runs, so `stable_id` means a durable
identity where reconciliation can preserve one, not an immutable row that is
guaranteed to exist forever. The `merge-similar-clusters` step rewrites the
smaller cluster's claim assignments (and children) to the larger cluster, then
deletes the smaller row. A `stable_id` can therefore disappear between polls
while its claims remain reachable under the surviving cluster. Topic
honest-rejection can also re-home Signals to an "Other developments" fallback
Topic: their `parent_stable_id` changes even though no new claim arrived.

There is currently no `updated_at` field or `since_updated` watermark. Relabels,
merges, and re-homes are detectable only by re-fetching clusters. Alongside
incremental claim polling, periodically perform a full re-sync of open Signals
(for example, daily). A merged-away `stable_id` has no tombstone or redirect.
Keep a supporting claim id and re-fetch that claim through
`GET /api/v1/claims/{id}`; its current `signal_stable_id` reveals the surviving
Signal.

Example level-0 response:

```json
{
  "clusters": [
    {
      "stable_id": "28eeab9d-47f6-4a2e-bd28-cafe6ad6ca19",
      "level": 0,
      "parent_stable_id": "18aad58a-73fb-4966-a640-626c79aa7d39",
      "label": "Flooding displaces families in Far North",
      "description": "Flooding damages homes and displaces families across several communities.",
      "country_code": "CM",
      "claim_count": 5,
      "source_count": 3,
      "last_claim_at": "2026-07-20T08:45:00.000Z",
      "formed_at": "2026-07-20T09:10:00.000Z",
      "topic": {
        "id": "18aad58a-73fb-4966-a640-626c79aa7d39",
        "label": "Far North climate and displacement"
      },
      "categories": [
        { "id": "displacement", "label": "Displacement & Migration" },
        { "id": "natural_disasters", "label": "Natural Disasters" }
      ],
      "rubric_tags": ["involves_displacement", "involves_flood"],
      "annotations": {
        "glide_v1": {
          "codes": ["fl"],
          "rationale": "The Signal describes a flood affecting several communities."
        },
        "acled_v1": {
          "codes": [],
          "rationale": "No ACLED conflict event appears in this Signal."
        },
        "iasc_v1": {
          "codes": ["wash", "shelter"],
          "rationale": "Flood damage affects safe-water and emergency-shelter response domains."
        }
      }
    }
  ],
  "nextPage": null,
  "pagination": {
    "limit": 50,
    "offset": 0,
    "count": 1,
    "level": 0
  }
}
```

For comparison, a level-1 Topic deliberately contains a self-reference:

```json
{
  "stable_id": "18aad58a-73fb-4966-a640-626c79aa7d39",
  "level": 1,
  "parent_stable_id": null,
  "topic": {
    "id": "18aad58a-73fb-4966-a640-626c79aa7d39",
    "label": "Far North climate and displacement"
  }
}
```

## `GET /api/v1/clusters/{stable_id}`

Returns one active cluster with the same row shape as an element of the
`/clusters` list, wrapped as `{ "cluster": ... }`. The path `stable_id` must be
a UUID; a non-UUID value returns `400 invalid_parameter`. An active cluster
outside the key's country grants returns `403 country_not_entitled`. A UUID
that does not identify an active cluster returns the JSON `404 not_found`
envelope.

In particular, a merged-away cluster returns `404`: the API does not expose a
tombstone. Its claims are re-homed to the survivor, so re-fetch a previously
associated claim with `GET /api/v1/claims/{id}` and read its current
`signal_stable_id` to reconcile the merge.

## `GET /api/v1/sources`

Returns active source-country memberships and trailing-30-day claim coverage.
Each row represents one `(source, country_code)` pair, so the same source `id`
can appear more than once when it feeds multiple country bundles. Rows are
ordered by source `id` ascending and then `country_code` ascending.

### Parameters

| Parameter | Meaning |
| --- | --- |
| `country` | ISO-2 country filter, repeatable or CSV. Matching is case-insensitive; omit it to use the key's full scope. |
| `language` | Language-code filter; repeat the parameter to request more than one language. |
| `limit` | Page size; default 100, maximum 500. Values above 500 clamp to 500. |
| `cursor` | Opaque endpoint-specific keyset cursor. Prefer the cursor in `nextPage`. An explicitly empty value returns `400 invalid_cursor`. |
| `offset` | Non-negative legacy offset, default 0. Ignored when `cursor` is present. |

Rows contain `id`, `name`, `url`, `domain`, `language`, `country_code`, `type`,
and `claim_count_30d`. There is intentionally no `status` field. `url` is the
canonical source URL and `domain` is its normalized domain. `country_code` is
the bundle membership represented by that row, not necessarily the source's
headquarters.

The `type` enum is:

| Value | Meaning |
| --- | --- |
| `blog` | A blog-format or lightly edited news publisher. |
| `broadcaster` | A television-first or multi-platform broadcast news outlet. |
| `gov_official` | An official government publisher or news outlet. |
| `magazine` | A magazine or periodical journalism outlet. |
| `news_aggregator` | A service that collects or curates reporting from other publishers. |
| `newspaper_independent` | An independently controlled newspaper or newsroom. |
| `newspaper_state` | A state-owned or state-controlled newspaper. |
| `radio` | A radio-first news outlet. |
| `wire` | A news agency or wire service that distributes reports to other outlets. |

This `(id, country_code)` order is a stable keyset order, not
`claim_count_30d` order. Treat `claim_count_30d` as coverage metadata, not as a
rank or relevance score.

```json
{
  "sources": [
    {
      "id": "a12e699c-cfa2-4ce9-8c01-bec72963ea91",
      "name": "Example News",
      "url": "https://news.example.org",
      "domain": "news.example.org",
      "language": "en",
      "country_code": "SD",
      "type": "newspaper_independent",
      "claim_count_30d": 143
    },
    {
      "id": "a12e699c-cfa2-4ce9-8c01-bec72963ea91",
      "name": "Example News",
      "url": "https://news.example.org",
      "domain": "news.example.org",
      "language": "en",
      "country_code": "SS",
      "type": "newspaper_independent",
      "claim_count_30d": 61
    }
  ],
  "nextPage": null,
  "pagination": {
    "limit": 100,
    "offset": 0,
    "count": 2
  }
}
```

## Pagination

`claims`, `clusters`, and `sources` return an absolute `nextPage` URL plus a
`pagination` object. Follow `nextPage` unchanged until it is `null`. It keeps
the active query parameters and supplies the server-issued cursor. `count` is
the number of rows in the current response, not a total-result count.

Only `/clusters` includes `pagination.level`. Its value is the string
`"auto"` when the request omits `level`, or the number `0` or `1` when the
request supplies that level. The key does not exist in `/claims` or `/sources`
responses.

Cursors are endpoint-specific and opaque. Do not decode, modify, manufacture,
or move one between endpoints. Cursors are also **not validated against the
filter set**: reusing a cursor with different country, time, level, language,
or other filters produces undefined results rather than a guaranteed error.
Always take the next cursor from the `nextPage` URL returned for the exact
query you are walking.

An absent `cursor` starts at the beginning. An explicitly empty `cursor=`
returns `400 invalid_cursor`.

`offset` remains available for compatibility. When a cursor is present, the
API ignores `offset` and reports an effective offset of `0`.

## Appendix: categories and rubric tags

The core filtering vocabulary defined by `FILTER_CATEGORIES` contains 11
categories and 44 distinct category-driving rubric tags. The full rubric grades
96 binary attributes per Signal — the 52 that drive no category are enumerated
in [Uncategorized rubric tags](#uncategorized-rubric-tags-52) at the end of
this appendix. Rubric grading is multi-label: a Signal can have any number of
true tags and can therefore belong to multiple categories. A cluster's `categories` array contains every category
with at least one true tag from that category's table. Each `field` string below
is the API value returned in `rubric_tags`; labels and descriptions are display
metadata.

Tags are graded per Signal. Topic `rubric_tags` and `categories` are the union
of child Signal values, with the analytical caveat described in
[Levels, tags, and annotations](#levels-tags-and-annotations).

| Category id | Label | Tag count |
| --- | --- | ---: |
| `violence_harm` | Violence & Harm | 8 |
| `conflict_intensity` | Conflict & Unrest | 5 |
| `displacement` | Displacement & Migration | 2 |
| `natural_disasters` | Natural Disasters | 5 |
| `health_humanitarian` | Health & Humanitarian | 2 |
| `environmental` | Environmental | 4 |
| `actors` | Actors Involved | 4 |
| `affected_populations` | Affected Populations | 3 |
| `women_peace_security` | Women, Peace & Security | 3 |
| `structural` | Structural Issues | 4 |
| `positive_signals` | Peace, Justice & Reform | 4 |
| **Total** | **11 categories** | **44** |

### `violence_harm` — Violence & Harm

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_death` | Deaths/Fatalities | Reports deaths or fatalities |
| `involves_physical_injury` | Injuries | Reports physical injuries |
| `involves_sexual_violence` | Sexual Violence | Sexual violence or assault |
| `involves_domestic_violence` | Domestic Violence | Domestic or intimate partner violence |
| `involves_kidnapping` | Kidnapping | Kidnapping or hostage-taking |
| `involves_detention_arrest` | Detention/Arrest | Arrests or detentions |
| `involves_property_destruction` | Property Destruction | Destruction of property |
| `civilians_directly_targeted` | Civilians Targeted | Civilians directly targeted |

### `conflict_intensity` — Conflict & Unrest

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_tensions_unrest` | Tensions/Unrest | Social tensions without physical violence |
| `involves_protest_demonstration` | Protests | Protests or demonstrations |
| `involves_low_intensity_violence` | Skirmishes | Low-intensity violence, clashes |
| `involves_firearms` | Firearms Used | Use of firearms or gunfire |
| `involves_explosives` | Explosives/IEDs | Bombs, IEDs, explosions |

### `displacement` — Displacement & Migration

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_displacement` | Displacement | People displaced from homes |
| `affects_displaced_persons` | Affects Refugees/IDPs | Impacts on already-displaced |

### `natural_disasters` — Natural Disasters

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_flood` | Flooding | Floods, flash floods |
| `involves_drought` | Drought | Drought, water scarcity |
| `involves_storm` | Storms | Hurricanes, cyclones, severe weather |
| `involves_earthquake_geological` | Earthquakes | Earthquakes, geological events |
| `involves_wildfire` | Wildfires | Wildfires, bush fires |

### `health_humanitarian` — Health & Humanitarian

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_disease_outbreak` | Disease Outbreak | Epidemics, health emergencies |
| `involves_humanitarian_response` | Aid Response | Humanitarian aid delivery |

### `environmental` — Environmental

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_environmental_damage` | Environmental Damage | General environmental harm |
| `involves_deforestation` | Deforestation | Forest destruction, logging |
| `involves_water_contamination` | Water Contamination | Water pollution or scarcity |
| `involves_extractive_industry` | Extractive Industry | Mining, oil/gas impacts |

### `actors` — Actors Involved

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_state_military` | State Military | National army, military forces |
| `involves_state_police` | Police/Security | Police, law enforcement |
| `involves_non_state_armed_group` | Armed Groups | Rebels, militias, insurgents |
| `involves_criminal_actors` | Criminal Actors | Gangs, cartels, bandits |

### `affected_populations` — Affected Populations

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `affects_women_girls` | Women & Girls | Gender-specific impacts |
| `affects_children` | Children | Children as victims |
| `affects_marginalized_community` | Marginalized Groups | Vulnerable communities |

### `women_peace_security` — Women, Peace & Security

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_women_as_actors` | Women as Actors | Women leading, organizing, mediating, advocating |
| `involves_gender_policy_action` | Gender Policy Action | Government/institutional action on gender equality |
| `involves_gender_targeted_rhetoric` | Gender-Targeted Rhetoric | Gender-based hate speech or incitement |

### `structural` — Structural Issues

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_discrimination` | Discrimination | Unequal treatment |
| `involves_marginalization` | Marginalization | Social exclusion |
| `involves_dispossession` | Dispossession | Loss of land/resources |
| `involves_chronic_deprivation` | Chronic Deprivation | Long-term poverty |

### `positive_signals` — Peace, Justice & Reform

| `rubric_tags` value | Label | Description |
| --- | --- | --- |
| `involves_peace_process` | Peace Process | Peace negotiations, ceasefires |
| `involves_mediation_dialogue` | Mediation/Dialogue | Conflict resolution efforts |
| `involves_accountability_justice` | Justice Mechanism | Trials, prosecutions, inquiries, transitional justice |
| `involves_policy_reform` | Policy/Legal Change | New laws, policy changes, institutional reforms |

### Uncategorized rubric tags (52)

`rubric_tags` is not limited to the 44 category-driving tags above. The full
rubric grades 96 binary attributes per Signal; the remaining 52 carry harm-type
detail, actor/population identification, causation, sector, and
verification/sourcing metadata that no analyst category groups. They appear in
`rubric_tags` exactly like the categorized tags and never contribute to
`categories`. Grouped by their internal rubric section:


#### Harm type

| `rubric_tags` value | Meaning |
| --- | --- |
| `involves_harassment_abuse` | Harassment, threats, intimidation, psychological abuse, coercive control, exploitation |
| `involves_harmful_traditional_practice` | FGM/C, forced/child marriage, honor-based violence, widow inheritance |
| `involves_forced_immobility` | Trapped populations, movement restrictions, inability to evacuate, siege conditions |
| `involves_looting_theft` | Looting, armed robbery, highway robbery, theft, pillaging |
| `involves_economic_loss` | Business closures, job losses, crop failures, financial losses, fiscal crisis |
| `involves_service_disruption` | Power outages, water supply cut, healthcare unavailable, schools closed |
| `involves_reproductive_health` | Maternal mortality, reproductive health access, pregnancy complications, family planning barriers |
| `involves_psychological_trauma` | Trauma, hopelessness, fear, despair, mental health crisis, loneliness epidemic |
| `involves_air_pollution` | Air quality issues, toxic fumes, emissions, smog, respiratory hazards |
| `involves_soil_contamination` | Heavy metals, toxic waste, soil poisoning, desertification, erosion |
| `involves_biodiversity_loss` | Species loss, wildlife deaths, endangered species impacts, ecosystem collapse, bushmeat hunting |
| `involves_extreme_temperature` | Heat wave, extreme cold, temperature-related deaths or impacts |
| `involves_arson` | Arson, deliberate burning of buildings/property, fire as weapon |
| `involves_crop_failure` | Failed harvests, crop destruction, agricultural damage, farm losses |
| `involves_livestock_loss` | Animal deaths, livestock disease, cattle raiding, herder losses |
| `involves_market_disruption` | Market closures, supply shortages, price spikes, food scarcity |

#### Actors

| `rubric_tags` value | Meaning |
| --- | --- |
| `involves_terrorist_designation` | Groups explicitly labeled as terrorist by governments or international bodies |
| `involves_civilian_perpetrators` | Mob violence, individual attackers, community violence |
| `involves_foreign_actors` | Foreign governments, international forces, cross-border actors |
| `involves_corporate_actors` | Companies, extractive industries, private contractors |
| `involves_conservation_actors` | Eco-guards, park rangers, conservation officers, environmental police |
| `involves_poachers_illegal_extractors` | Poachers, illegal loggers, artisanal/illegal miners, smugglers of natural resources |
| `involves_traditional_authorities` | Chiefs, traditional councils, customary leaders, elders |
| `involves_civil_society` | NGOs, community organizations, advocacy groups, humanitarian organizations |
| `involves_international_organizations` | UN agencies, international NGOs, peacekeepers, international monitors |

#### Affected populations

| `rubric_tags` value | Meaning |
| --- | --- |
| `affects_general_population` | Broad impact not targeting specific demographics |
| `affects_specific_ethnic_group` | Ethnic targeting, inter-ethnic violence, ethnic minorities affected |
| `affects_specific_religious_group` | Religious targeting, sectarian violence, religious minorities affected |
| `affects_elderly` | Elderly/aged as victims or disproportionately affected |
| `affects_journalists_activists` | Press freedom, civil society targeting, activist persecution |
| `affects_healthcare_workers` | Attacks on health workers, hospitals targeted, medical staff affected |

#### Causation

| `rubric_tags` value | Meaning |
| --- | --- |
| `cause_natural` | Weather, geological events, natural disasters, disease (natural origin) |
| `cause_intentional_human` | Deliberate attack, planned action, intentional harm |
| `cause_unintentional_human` | Accidents, negligence, unintended consequences, human error |
| `cause_institutional_systemic` | Policy failures, structural issues, systemic neglect, institutional causes |
| `cause_infrastructure_failure` | Road collapse, dam failure, building collapse, utility failure |
| `involves_bureaucratic_delay` | Administrative delays, permit denials, bureaucratic obstruction, slow response |

#### Sectors

| `rubric_tags` value | Meaning |
| --- | --- |
| `affects_healthcare_system` | Hospitals, clinics, health services, medical supplies, healthcare access |
| `affects_education_system` | Schools closed, education disrupted, students affected |
| `affects_water_sanitation` | Water access, clean water, sanitation systems, WASH |
| `affects_food_security` | Food access, agriculture, food prices, hunger, nutrition |
| `affects_shelter_housing` | Homelessness, housing destruction, shelter needs |
| `affects_transportation` | Roads, bridges, airports, transit systems, mobility |
| `affects_communications` | Internet, phones, media, communications blackouts |
| `affects_livelihoods` | Jobs, businesses, economic activities, income sources |
| `affects_governance` | Elections, government function, political institutions, rule of law |
| `involves_reintegration` | DDR programs, ex-combatant reintegration, refugee return, community reintegration |
| `involves_community_resilience` | Local coping strategies, community-led response, self-organization, mutual aid |

#### Verification & sourcing

| `rubric_tags` value | Meaning |
| --- | --- |
| `official_source_cited` | Government statements, official reports, institutional data |
| `multiple_sources_mentioned` | Corroboration from multiple sources mentioned in the content |
| `firsthand_account` | Direct witness testimony, on-the-ground reports, first-person accounts |
| `numbers_explicitly_estimated` | Numbers qualified as estimates, approximations, or uncertain |

## Appendix: annotation layers and code vocabularies

Annotations appear under their versioned layer id:

```json
{
  "annotations": {
    "glide_v1": {
      "codes": ["fl"],
      "rationale": "The Signal describes river flooding."
    }
  }
}
```

API codes use lowercase `snake_case` (or lowercase two-letter codes for
GLIDE), while the tables below also show the canonical value in the attributed
source standard.

The `_v1` suffix pins the vocabulary. Codes are only added or changed under a
new layer id, so a client enum cannot silently break within a layer version.

### `glide_v1` — GLIDE disaster event types (25 codes)

GLIDE is the GLobal IDEntifier number disaster-event taxonomy. API values are
lowercase; canonical GLIDE codes are uppercase two-letter codes.

| API code | Canonical GLIDE value | Meaning |
| --- | --- | --- |
| `cw` | `CW` — Cold Wave | Cold-wave events, including cold spells, cold snaps, extreme cold, freezing temperatures, and severe frost. |
| `ht` | `HT` — Heat Wave | Heat-wave events, including extreme, record, scorching, or prolonged heat. |
| `ce` | `CE` — Complex Emergency | A multifaceted or protracted humanitarian crisis, including conflict-driven emergencies or combined shocks. |
| `dr` | `DR` — Drought | Prolonged dry conditions such as rainfall deficit, water scarcity, crop failure, or a failed rainy season. |
| `eq` | `EQ` — Earthquake | Seismic events including earthquakes, quakes, tremors, and aftershocks. |
| `ep` | `EP` — Epidemic | A disease epidemic or outbreak, including rapid transmission and public-health emergencies. |
| `ec` | `EC` — Extratropical Cyclone | A mid-latitude or temperate cyclone, frontal storm, winter cyclone, or intense low-pressure system. |
| `fr` | `FR` — Fire | A fire event such as a structure, urban, building, or industrial fire. |
| `ff` | `FF` — Flash Flood | Sudden, rapid-onset flooding, often associated with torrential rain or runoff. |
| `fl` | `FL` — Flood | General flooding or inundation, including river overflow and seasonal flooding. |
| `in` | `IN` — Insect Infestation | An agricultural insect or pest outbreak, including locust swarms and armyworm infestation. |
| `sl` | `SL` — Slide | Ground, soil, earth, or slope movement and instability classified as a slide. |
| `ls` | `LS` — Land Slide | A landslide or land slide, including slope failure, hillside collapse, and debris slide. |
| `ms` | `MS` — Mud Slide | A mudslide, mudflow, or saturated debris flow, including rain-triggered mud movement. |
| `av` | `AV` — Snow Avalanche | A snow avalanche, snow-slide, slab release, or snowpack collapse. |
| `st` | `ST` — Severe Local Storm | A severe local or convective storm, including thunderstorms, hailstorms, and damaging windstorms. |
| `ac` | `AC` — Technological Disaster | A technological or industrial accident such as a chemical/oil spill, radiological incident, infrastructure failure, or dam failure. |
| `to` | `TO` — Tornado | A tornado or tornadic vortex, including funnel-cloud and touchdown events. |
| `tc` | `TC` — Tropical Cyclone | A tropical cyclone, hurricane, typhoon, or tropical storm, including landfall. |
| `ss` | `SS` — Storm Surge | Storm-driven coastal surge or inundation, including high storm tide and seawater intrusion. |
| `ts` | `TS` — Tsunami | A tsunami or seismic sea wave, including coastal wave run-up and inundation. |
| `vo` | `VO` — Volcano | A volcanic eruption or activity, including ash, lava, pyroclastic flow, and tephra fall. |
| `wf` | `WF` — Wild Fire | A wildfire or uncontrolled forest, bush, vegetation, or grass fire. |
| `vw` | `VW` — Violent Wind | Destructive high-wind events, including damaging gusts and gale-force winds. |
| `ot` | `OT` — Other | An emergency, humanitarian situation, or security incident not classified under another GLIDE event type. |

### `acled_v1` — ACLED event sub-types (25 codes)

ACLED is the Armed Conflict Location & Event Data Project taxonomy. API codes
are slugified forms of ACLED's canonical sub-event type names. The event-type
family shown in each meaning is also taken from the layer definition.

| API code | Canonical ACLED sub-event type | Meaning |
| --- | --- | --- |
| `armed_clash` | Armed clash | A `Battles` sub-event recording an armed clash. |
| `government_regains_territory` | Government regains territory | A `Battles` sub-event in which government forces regain territory. |
| `non_state_actor_overtakes_territory` | Non-state actor overtakes territory | A `Battles` sub-event in which a non-state actor takes territory. |
| `chemical_weapon` | Chemical weapon | An `Explosions/Remote violence` sub-event involving a chemical weapon. |
| `air_or_drone_strike` | Air/drone strike | An `Explosions/Remote violence` sub-event involving an air or drone strike. |
| `suicide_bomb` | Suicide bomb | An `Explosions/Remote violence` sub-event involving a suicide bomb. |
| `shelling_artillery_missile` | Shelling/artillery/missile attack | An `Explosions/Remote violence` sub-event involving shelling, artillery, or a missile attack. |
| `remote_explosive_landmine_ied` | Remote explosive/landmine/IED | An `Explosions/Remote violence` sub-event involving a remote explosive, landmine, or IED. |
| `grenade` | Grenade | An `Explosions/Remote violence` sub-event involving a grenade. |
| `sexual_violence` | Sexual violence | A `Violence against civilians` sub-event specifically involving sexual violence; it does not mean restrictions on women's rights. |
| `attack` | Attack | A `Violence against civilians` event using ACLED's general Attack sub-event. |
| `abduction_forced_disappearance` | Abduction/forced disappearance | A `Violence against civilians` sub-event involving abduction or forced disappearance. |
| `peaceful_protest` | Peaceful protest | A `Protests` sub-event recording a peaceful protest. |
| `protest_with_intervention` | Protest with intervention | A `Protests` sub-event recording intervention in a protest. |
| `excessive_force_against_protesters` | Excessive force against protesters | A `Protests` sub-event recording excessive force against protesters. |
| `violent_demonstration` | Violent demonstration | A `Riots` sub-event recording a violent demonstration. |
| `mob_violence` | Mob violence | A `Riots` sub-event recording mob violence. |
| `agreement` | Agreement | A `Strategic developments` sub-event recording an agreement. |
| `arrests` | Arrests | A `Strategic developments` sub-event recording arrests. |
| `change_to_group_or_activity` | Change to group/activity | A `Strategic developments` sub-event recording a change to a group or its activity. |
| `disrupted_weapons_use` | Disrupted weapons use | A `Strategic developments` sub-event for physical weapon seizure or interception, not arms-control talks or agreements. |
| `headquarters_or_base_established` | Headquarters or base established | A `Strategic developments` sub-event recording establishment of a headquarters or base. |
| `looting_property_destruction` | Looting/property destruction | A `Strategic developments` sub-event involving looting or property destruction. |
| `non_violent_transfer_of_territory` | Non-violent transfer of territory | A `Strategic developments` sub-event recording a non-violent territorial transfer. |
| `other_strategic_development` | Other | ACLED's other `Strategic developments` sub-event. |

### `iasc_v1` — IASC humanitarian coordination clusters (11 codes)

IASC is the Inter-Agency Standing Committee cluster approach. API codes map to
the canonical humanitarian coordination cluster names.

| API code | Canonical IASC cluster | Meaning |
| --- | --- | --- |
| `protection` | Protection | Safety, dignity, and rights of affected people; child protection, GBV response, mine action, housing/land/property, and rule of law. |
| `health` | Health | Disease outbreaks, medical care, health facilities and workers, immunization, and mental health. |
| `wash` | Water, Sanitation and Hygiene | Safe-water access, sanitation, hygiene, and disease-vector control. |
| `shelter` | Shelter and NFI | Emergency shelter, housing, and non-food relief items. |
| `food_security` | Food Security | Food access and availability, agriculture, livelihoods, and market disruption affecting food. |
| `nutrition` | Nutrition | Malnutrition, therapeutic feeding, and infant and young-child feeding. |
| `education` | Education | Access to schooling, attacks on education, and learning continuity in emergencies. |
| `cccm` | Camp Coordination and Camp Management | Management of displacement sites and camps, site services, and population tracking. |
| `logistics` | Logistics | Supply chains, transport, storage, and access constraints for relief. |
| `early_recovery` | Early Recovery | Restoration of basic services, livelihoods, governance, and infrastructure after crisis. |
| `emergency_telecommunications` | Emergency Telecommunications | Communications infrastructure, connectivity, and data services for response, including telecom or internet blackouts. |
