# Feeds: the data that is pinned, and how to check it

Every number Craton produces comes out of a **pinned snapshot**: a dated copy of a
public publisher's response, committed to the build with the SHA-256 of its
bytes. A price, a backtest and a model run read those files and nothing else,
which is why a result replays byte for byte a year later.

That single fact gives this page its two halves, and most readers arrive needing
one of them:

* **Writing a structure.** A structure names a feed by `id` and `version`, and
  **both must already be pinned** — [what is pinned right
  now](#what-is-pinned-right-now) is the list to write your `data_sources` block
  from, and [when a feed is not pinned](#when-a-feed-is-not-pinned) is the
  refusal you get if you name one that is not on it.
* **Checking a result.** Every technical price and every backtest carries an
  assumption set, and in it a list of data sources: an id, a version, and the
  SHA-256 of the exact bytes that version is. [Checking the data a result was
  computed from](#checking-the-data-a-result-was-computed-from) turns those
  three strings into the file itself and proves the digest is the digest.

Read [the quickstart](quickstart.md) first for the two values you export
(`CEDE_BASE_URL`, `CEDE_API_KEY`) and how to get a key from `POST /signup`.

## Why the data is pinned rather than fetched

A model is deterministic against versioned snapshots and makes no network call
while it runs, so a result replays byte for byte. That is only true if the data
cannot move underneath it, so Craton holds a **snapshot**: the publisher's
response, committed verbatim, with a manifest recording where it came from, what
it covers, and its digest.

A snapshot is immutable once published. A refreshed catalogue is a **new
version**, never an edit of the old one — which matters more than it sounds for
a revised catalogue like the USGS one, where re-running the publisher's own
query tomorrow does *not* return today's bytes. The frozen copy is the point.
Both versions stay in the table below until the old one is retired, and a result
you computed last month keeps naming the exact bytes it read.

The same rule read from the other end is the thing this page exists to say out
loud: a vintage that was never committed **cannot be fetched on demand**. The
refusal you get for a feed id nobody pinned is not a permissions problem or a
typo the engine will forgive — it is determinism doing its job.

---

## What is pinned right now

| Feed id | Version | Kind | What it measures | Period of record | Licence |
| --- | --- | --- | --- | --- | --- |
| `era5-rain-bangkok` | `2026-08-11` | `rainfall_daily` | Daily precipitation total, mm, at the Bangkok ERA5 grid point (13.743409N, 100.495865E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
| `era5-wind-galveston` | `2026-08-15` | `wind_daily` | Daily maximum 10 m wind speed, mph, at the Galveston ERA5 grid point (29.279436N, -94.87326E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
| `era5-wind-miami` | `2026-08-14` | `wind_daily` | Daily maximum 10 m wind speed, mph, at the Miami ERA5 grid point (25.764498N, -80.196075E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
| `era5-wind-new-orleans` | `2026-08-15` | `wind_daily` | Daily maximum 10 m wind speed, mph, at the New Orleans ERA5 grid point (29.982424N, -90.10489E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
| `era5-wind-tampa` | `2026-08-15` | `wind_daily` | Daily maximum 10 m wind speed, mph, at the Tampa ERA5 grid point (27.94376N, -82.49155E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
| `usgs-eq-kanto` | `2026-08-10` | `quake_catalogue` | Earthquake hypocentres and preferred magnitudes, M4.5+, in 34.0–37.0N / 138.0–141.5E (2409 events) | 1986-01-01 to 2026-01-01 (40 years) | Public domain (USGS) |

A feed id is a *place and a source*, not a peril: `usgs-eq-kanto` is the USGS
catalogue **for the Kanto box**, and the region it covers is part of what makes
it the right feed. There is no all-of-the-world quake feed here to fall back
on, by design — a snapshot that covers less than the box you priced would find
fewer events than really occurred and hand you a technical price that is too
low for a reason nothing records. Coverage is checked on every call and a
structure reaching outside it is refused with the edge it crossed.

### The wind series, and what a named-storm structure can ask of them

Four of the snapshots are daily maximum wind, and they are the ones a
`tropical_cyclone` structure attaches on — one on the Atlantic coast of
Florida (`era5-wind-miami`) and three on the Gulf Coast
(`era5-wind-galveston`, `era5-wind-new-orleans`, `era5-wind-tampa`). Two
things about all four decide whether the ladder you write can be measured at
all, and both are in each manifest's `notes` as well as here.

**They measure wind, not storms.** A daily wind maximum at a grid point is not
a storm track archive: it carries no storm names, no tracks, no radii and no
landfall points. A trigger written on one pays on wind speed alone — whether
or not a meteorological agency named the system that produced it — and it
cannot exclude a wind day that no cyclone caused. If your wording requires a
*named* system, this build measures something adjacent to it, not it.

**The levels are levels of the feed.** Each series is a cell average of hourly
means, so it reads well below an anemometer in the eyewall and further below a
gust. Written in the alphabet of the Saffir-Simpson ladder, a 74 mph
attachment is above nearly everything forty years of these reanalysis cells
hold. What the records actually reach, as the number of years out of 40 whose
highest day reaches each level:

| Feed | Highest day in the record | ≥40 mph | ≥50 mph | ≥60 mph | ≥74 mph |
| --- | --- | --- | --- | --- | --- |
| `era5-wind-galveston` | 60.0 mph (2024-07-08) | 8 | 2 | 1 | 0 |
| `era5-wind-miami` | 77.1 mph (2005-10-24) | 4 | 2 | 2 | 1 |
| `era5-wind-new-orleans` | 73.2 mph (2005-08-29) | 4 | 3 | 2 | 0 |
| `era5-wind-tampa` | 55.1 mph (2024-10-10) | 4 | 1 | 0 | 0 |

Read that as the shape of the structure, not as a recommendation: a band no
year reaches earns no expected loss from an empirical burn, and nothing here
fits a distribution to the tail. Which of two things happens to your ladder
depends on whether **any** of it is inside the record:

* **Part of it is.** The ladder prices off the years that reach its
  attachment. The bands nothing reached carry `occurrences: 0`, a `null`
  return period, and a sentence in `assumptions.limitations` saying the price
  understates them.
* **None of it is.** The structure is **refused, not priced at zero**. The
  price job ends `failed` carrying `422 unpriceable_object`, and the message
  names the highest level the record reached and the day it happened. The zero
  such a ladder would otherwise price at is not a cheap layer; it is the
  absence of any observation in the structure's trigger range, and the two read
  identically in every field of a price response, so this build declines to
  publish it. [A structure the record never paid on is refused, not priced at
  zero](price.md#a-structure-the-record-never-paid-on-is-refused-not-priced-at-zero)
  is the whole refusal and the decision behind it.

**A Saffir-Simpson ladder is the second case on three of these four feeds.**
The first rung — 74 mph, as 1-minute sustained wind — is reached in one year
of forty at `era5-wind-miami` and in **no year at all** at
`era5-wind-galveston`, `era5-wind-new-orleans` and `era5-wind-tampa`; that is
the last column of the table, read straight across. A category-1-and-above
ladder on any of those three is refused every time rather than priced, and the
refusal names the number to attach at instead. Galveston tops out at 60.0 mph,
so a 75 mph attachment there gets:

```text
no year of the pinned record would have paid: this structure first pays at
75 mph and the highest 1-day rolling_maximum of daily_maximum_wind_speed_mph
in era5-wind-galveston@2026-08-15 over 1986-2025 is 60.0 mph, in 2024 (window
2024-07-08 to 2024-07-08). No year in the period of record reaches 75, 96,
111 mph, so the empirical burn assigns those bands no expected loss at all —
every level of this ladder, not only its attachment, sits above the record.
[…] Attach at or below 60.0 mph to price against the observed record, or run
POST /structures/{id}/backtest to see the same 40 years with no payout in
them, which is a description of history rather than a price.
```

Which is to say: the table above is the ladder you can measure here, and a
category written on top of it is a different measurement — pick the attachment
from the record, not from the alphabet.

Two other ways to read the same list, both current for the environment you are
pointed at, which a page cannot be:
[`GET /primitives`](#enumerating-them-from-the-api) and
[`GET /feeds`](#the-three-routes).

### The measurement variable each feed publishes

The **What it measures** column above is English. `trigger.index.measurement`
is machine-readable, and the two are not the same string. This is the table to
copy `variable` and `unit` from, exactly as written:

| Feed | `measurement.variable` | `measurement.unit` | The publisher's own name for it |
| --- | --- | --- | --- |
| `era5-rain-bangkok@2026-08-11` | `daily_precipitation_mm` | `mm` | `precipitation_sum` |
| `era5-wind-galveston@2026-08-15` | `daily_maximum_wind_speed_mph` | `mph` | `wind_speed_10m_max` |
| `era5-wind-miami@2026-08-14` | `daily_maximum_wind_speed_mph` | `mph` | `wind_speed_10m_max` |
| `era5-wind-new-orleans@2026-08-15` | `daily_maximum_wind_speed_mph` | `mph` | `wind_speed_10m_max` |
| `era5-wind-tampa@2026-08-15` | `daily_maximum_wind_speed_mph` | `mph` | `wind_speed_10m_max` |
| `usgs-eq-kanto@2026-08-10` | `catalogue_preferred_magnitude` | `M` | `mag` |

Every row is a snapshot, not a feed: the variable is a property of what the
snapshot holds, so a new vintage gets its own row and is checked on its own
bytes. The four wind series repeat one variable because they are the same
measurement read at four grid points — the point is chosen by naming the feed
in `trigger.index.feed`, never by varying this string.

That last column is not a second name you may use. It is there to head off the
three plausible strings that are not the variable:

* **The manifest's name is not the variable.** `GET /feeds/{id}/versions/{v}`
  reports `coverage.measurement` — `wind_speed_10m_max` for the Miami feed —
  because that is the field the publisher was asked for in `query_url`, and
  keeping it is how a snapshot traces back to its source. Written into
  `measurement.variable` it is refused like any other unknown string.
* **Nor is the prose.** A feed described as a daily maximum sustained wind at
  10 m does not therefore publish `wind_speed_sustained_10m`. The engine
  matches the variable exactly and never guesses at intent: a name it
  half-recognised is how a rainfall total gets read as a wind speed.
* **Nor are the schema's examples.** `measurement.variable` is any non-empty
  string as far as the published schema is concerned, so an object naming
  `one_minute_sustained_wind` validates and then prices against nothing. The
  example objects on the [open schema](open-schema.md) surface illustrate the
  *shape* of a risk object; what a given deployment can actually measure is
  this table and `GET /primitives`.

Get it wrong and the refusal names what the feed does publish — a job that ends
`failed` carrying `422 unpriceable_object`, with:

```text
trigger.index: feed era5-wind-miami@2026-08-14 publishes
daily_maximum_wind_speed_mph; it does not publish 'wind_speed_sustained_10m'
```

`unit` is checked separately and just as exactly, against the unit the feed
publishes the variable in — a daily series declared in `kt` over a feed
published in `mph` is refused rather than converted, because a silent
conversion would move every level of the payout ladder.

`measurement.statistic` is the third member, and it says how the daily values
become one index level: `sum`, `peak` and `count` for a daily series (the
`rolling_sum`, `rolling_maximum` and `threshold_day_count` transforms —
[compose a model](model-builder.md) walks them), `max` for the catalogue's
per-event magnitude.

`GET /primitives` carries the same `variable` and `unit` for every feed the
**model builder** reads, in `feeds[].measurements[]`. That list is empty for
the quake catalogue, because a v1 model composes a daily series and the
catalogue is not one; the cat-in-a-box price model reads its magnitude all the
same, which is why the row above exists and the API list is not a substitute
for it.

---

## Naming one in a risk object

`trigger.data_sources` takes exactly one entry. Combining feeds is a later
model rather than an averaging this build does silently.

```json
"data_sources": [
  { "id": "usgs-eq-kanto",
    "version": "2026-08-10",
    "kind": "quake_catalogue",
    "vintage": "2026-08-10" }
]
```

`id` and `version` are the first two columns of the table above, copied
exactly. `version` is the vintage date — not today's date, and not a semver.

`kind` is the caller's declaration of **what shape of source this is**, drawn
from the enum the published schema fixes, and it is not the same vocabulary as
the manifest's `Kind` column. What each trigger needs:

| Trigger | `data_sources[0].kind` | Feed kinds the model reads | Model |
| --- | --- | --- | --- |
| `parametric_cat_in_a_box` | `quake_catalogue` | `quake_catalogue` | `cede/parametric-burn-eq-box` |
| `parametric_index` | `station_network` | `rainfall_daily`, `wind_daily` | `cede/parametric-burn-station-index` |

For `parametric_index` the declaration is checked first and the manifest
second, before a byte of the series is parsed: a `station_network` declaration
over a quake catalogue is refused as the disagreement it is rather than read as
millimetres. A daily series is declared `station_network` even though its
manifest says `rainfall_daily` or `wind_daily`, because the object's vocabulary
describes the *source* and the manifest's describes the *contents*.

Which perils each feed kind can carry — a wind series prices
`tropical_cyclone`, a rainfall series does not — is in
[a technical price](price.md#what-is-in-a-technical-price), with the reason.

---

## Enumerating them from the API

`GET /primitives` returns the same list, plus the measurement variables and
units of each feed and the transforms and payout functions a model may compose
with. It reads the pinned tree at request time:

```sh
curl -sS -H "Authorization: Bearer $CEDE_API_KEY" "$CEDE_BASE_URL/primitives" \
  | python3 -c 'import json,sys
for feed in json.load(sys.stdin)["feeds"]:
    print(feed["id"], feed["version"], feed["kind"], sep="\t")'
```

That endpoint never serves feed *content* — a feed is enumerated, described and
licensed there. Content has its own route, under its own rule; see
[what `content_servable: false` means](#what-content_servable-false-means).

---

## When a feed is not pinned

Naming a feed or a vintage that is not in the table produces a refusal that
lists what is:

```text
no pinned snapshot for feed 'usgs-eq-ca' version '2026-08-10' (nothing at
…/usgs-eq-ca/2026-08-10/feed.json). No feed called 'usgs-eq-ca' is pinned
in this build. The closest pinned feed id is 'usgs-eq-kanto'. Pinned feeds:
era5-rain-bangkok@2026-08-11, era5-wind-miami@2026-08-14,
usgs-eq-kanto@2026-08-10. GET /primitives lists every pinned feed with its
kind, coverage and licence, and docs/feeds.md enumerates the same list. The
engine reads snapshots and never the network, so a feed version a risk object
names must be committed before it can be priced against.
```

The path in the first parenthesis is abbreviated above: in full it names the
place inside Craton's own build where that vintage's manifest would live, which
is a fact about Craton rather than a file on your machine. Nothing there is for
you to create — the two strings you can act on are the `id` and the `version`,
and the sentences after it are what tell you which ones to write.

Where the refusal reaches you depends on the verb, and the wrapper differs by
verb while the sentence above is the same:

| Verb | How it arrives |
| --- | --- |
| `POST /objects/{id}/price` | The job ends `failed` carrying `422 unpriceable_object`. |
| `POST /structures/{id}/backtest` | The job ends `failed` carrying `422 unbacktestable_structure`. |
| `POST /models` | `422 invalid_composition`, immediately — the model is not created. |

Read the message rather than matching on it: messages are written for humans
and get rewritten whenever a clearer sentence exists. The `code` is the stable
part. [The error reference](errors.md) is the full list of codes.

---

## Checking the data a result was computed from

The other half of the page: taking the `id`, `version` and `sha256` out of an
assumption set and getting to the bytes themselves.

### The three routes

| Route | What it answers |
|---|---|
| `GET /feeds` | Every feed and every version this environment holds, with the full digest, the record count, the byte length and the paths below |
| `GET /feeds/{feed_id}/versions/{version}` | One version as a registry object: the manifest as committed — publisher, retrieval query, coverage, licence, aggregation level, and the notes saying what the snapshot is and is not |
| `GET /feeds/{feed_id}/versions/{version}/data` | The publisher's response, verbatim, in a JSON envelope carrying the pinned digest beside it |

All three take your key. None of them is metered: reading the lineage of a
result is not an object and not a model run, and checking Craton's homework should
not carry a price.

### What you need

* the same two exports as the quickstart, and `curl` 7.76 or newer with
  `python3`;
* an environment whose `GET /health` answers. Everything below is a call
  against it — nothing here reads a file you do not create.

### Walk it

The block below lists the feeds, reads one version's manifest, downloads its
bytes, and — the only step that really matters — recomputes the SHA-256 of what
it downloaded and compares it with what the manifest pins. It exits non-zero if
they differ.

<!-- cede:runnable -->

```bash
set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"
: "${CEDE_API_KEY:?export CEDE_API_KEY first — see 'What you need'}"

# 1 — Every feed and version this environment holds.
curl -sS "$CEDE_BASE_URL/feeds" \
  -H "Authorization: Bearer $CEDE_API_KEY" > feeds.json

python3 -c 'import json
index = json.load(open("feeds.json"))
for feed in index["feeds"]:
    print(feed["id"], "—", feed["name"])
    for version in feed["versions"]:
        print("   ", version["version"],
              version["sha256"][:12] + "…",
              str(version["bytes"]) + " bytes",
              "servable" if version["content_servable"] else "manifest only")'

# 2 — Pick the first version of the first feed and read its manifest.
FEED_ID="$(python3 -c 'import json; print(json.load(open("feeds.json"))["feeds"][0]["id"])')"
VERSION="$(python3 -c 'import json; print(json.load(open("feeds.json"))["feeds"][0]["versions"][0]["version"])')"
echo "inspecting ${FEED_ID}@${VERSION}"

curl -sS "$CEDE_BASE_URL/feeds/$FEED_ID/versions/$VERSION" \
  -H "Authorization: Bearer $CEDE_API_KEY" > manifest.json

python3 -c 'import json
manifest = json.load(open("manifest.json"))
print("publisher :", manifest["publisher"])
print("licence   :", json.dumps(manifest.get("licence", "unstated")))
print("coverage  :", json.dumps(manifest.get("coverage", {})))
print("aggregation level:", manifest["aggregation_level"]["level"])
print("pinned sha256    :", manifest["sha256"])'

# 3 — The bytes themselves.
curl -sS "$CEDE_BASE_URL/feeds/$FEED_ID/versions/$VERSION/data" \
  -H "Authorization: Bearer $CEDE_API_KEY" > data.json

# 4 — The check. Write the content out and take its digest: it must equal the
#     sha256 the manifest pins, which is the sha256 every price and backtest
#     that read this version names in its assumption set.
python3 -c 'import hashlib, json, sys
document = json.load(open("data.json"))
raw = document["content"].encode(document["charset"])
open("snapshot.csv", "wb").write(raw)
computed = hashlib.sha256(raw).hexdigest()
print("pinned   :", document["sha256"])
print("computed :", computed)
print("bytes    :", len(raw), "declared:", document["bytes"])
if computed != document["sha256"]:
    sys.exit("the bytes served do not match the digest they are pinned to")
print("MATCH — this is the data the results were computed from")'

# 5 — It is an ordinary file. Here are its first three lines.
head -3 snapshot.csv
```

### Reading the answer

`content` holds the file's bytes exactly as committed: nothing between the
snapshot and the response reorders columns, reformats numbers or re-escapes
fields. That is why the digest matches, and it is also why the bytes travel
inside a JSON envelope rather than as a raw body — every Craton response validates
against a published schema, and a bare file would be the one response with no
schema to validate it against. `bytes` is the length of the file, not of the
envelope, so you can see the size before you ask for it.

The published schemas for all three documents are on the
[open schema](open-schema.md) surface, no account required:
`feed-index.v0.schema.json`, `feed-object.v0.schema.json` and
`feed-data.v0.schema.json`.

### What `content_servable: false` means

Feeds are aggregate only — tables and indices at portfolio level, never
individual-level records. Every manifest declares its aggregation level, and
content is served only for levels that cannot carry such a record by
construction: a public hazard-event catalogue, a gridded reanalysis cell. Any
other level is refused with `403 feed_not_servable`, and the index says so ahead
of time. The manifest, its coverage and its digest stay readable either way:
naming a feed is not serving its content.

### Tying it back to a result

A backtest's `assumptions.data_sources[0]` gives you `id`, `version` and
`sha256`. Put the first two into the routes above; the third is what the walk
compares against. If they match, the history you are reading was computed from
the bytes you now hold — and if you keep them, you can recompute it yourself.

The digest is also why a snapshot cannot quietly rot: it is recomputed before
any row is parsed, so a snapshot whose bytes have drifted is an error rather
than a silent re-fetch. You cannot get a number out of Craton that was computed
from data nobody can produce again.

---

## Troubleshooting

| What you see | What it means |
|---|---|
| `404 not_found` | No such feed id or version in this environment. `GET /feeds` lists exactly what it holds. |
| `403 feed_not_servable` | The snapshot is there, but its declared aggregation level is not one whose content is served. Read the manifest instead. |
| `401 unauthenticated` | No key, or a key this environment does not hold. The header is exactly `Authorization: Bearer <key>`. |
| A digest that does not match | Report it. Craton recomputes the digest before serving a byte and refuses a drifted snapshot, so a mismatch reaching you should be impossible. |
| `no pinned snapshot` on a price, backtest or model run | The `id` or the `version` you named is not one this environment pins. [When a feed is not pinned](#when-a-feed-is-not-pinned) has the refusal and what to write instead. |

Adding a feed means committing a new snapshot to the build, so today it goes
through whoever operates the deployment. The routes above read what is there;
they are enumeration, not a way to write to it.
