# Monitor: record a structure in force, then read the measurement

`POST /structures/{id}/monitoring` records that a structure is in force.
`GET /monitoring` and `GET /monitoring/{id}` read the measurement back, and
recompute it every time you ask. `POST /monitoring/{id}/webhooks` has the
measurement delivered to an address of yours when it moves, and
`GET /monitoring/{id}/webhooks` shows you every delivery that was attempted.

Two halves, and they are not the same kind of thing. **Recording is your
assertion.** You are telling Craton that a structure it holds is in force under a
transaction executed entirely outside Craton — Craton did not arrange it, execute
it or witness it, holds no document evidencing it, is not a party to it and is
not an intermediary in it. Craton stores what you said, attributed to you, and
that is the whole of it. **A read is a measurement.** Craton reads the
structure's own index against the pinned public record, and reports the level,
the levels it is read against, and the distance between them.

What a read is not, stated once and then carried in every response:

* **not a determination.** When a level is reached, the response says the level
  was reached. It says nothing about what follows from that, about anybody's
  obligations under any contract, or about any amount of money. There is no
  amount of money anywhere in a monitoring response.
* **not a notice under anybody's contract.** Craton is not the calculation agent
  of record for anything and has no mechanism to become one. A webhook delivers
  the difference between two readings of your index — see [deliver it, when it
  moves](#deliver-it-when-it-moves) — and Craton sends no claim notice, no
  settlement notice and no calculation-agent notice, to you or to anybody else.
* **not a live sensor feed.** Every feed in this build is a pinned, digest-
  addressed snapshot and no evaluation reaches the network, so "live" means
  *the newest pinned vintage of the feed your trigger names* — see [the
  vintage a read uses](#the-vintage-a-read-uses).

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`.
[Backtest a structure](backtest.md) is the page that gets you a structure to
record; this one repeats the object it uses so the walk below stands alone.

## The vintage a read uses

A price and a backtest read the feed **version your trigger pins**. That is
what makes them replay byte for byte, years later, into the same digits.

Monitoring cannot work that way and be worth reading twice, so it reads the
**newest pinned vintage of the same feed** and tells you, in every response,
which vintage it used, which one your structure pins, and whether they are the
same:

```json
"vintage_selection": {
  "pinned_version": "2026-08-10",
  "read_version": "2026-08-10",
  "is_the_pinned_version": true,
  "rule": "An observation reads the newest pinned vintage of the feed this structure's trigger names…"
}
```

Nothing about your structure changes when a newer vintage lands. Your price and
your backtest still read the version you pinned; your next monitoring read
moves to the newer one and says that it moved.

## The days a read measures

The index is measured over the **overlap** of your cover period with the
snapshot's period of record, and the response reports that window:
`measured_from`, `measured_to`, `record_ends`, `days_measured`,
`days_not_yet_measured`.

Neither end of that is negotiable. Measuring past the end of a snapshot would
report a level computed from whichever days happened to be inside the file.
Measuring the whole term as though it had elapsed would report a level for days
that have not happened.

So a term that is entirely ahead of the record — which, with the feeds this
build pins today, is any term in 2026 or later — reads back as
`"index_value": null` with an `unmeasured_reason` naming both windows. **Null is
not zero.** A level of zero is a measurement; an absence is not, and a client
that read one as the other would report a quiet term when what it had was no
data. The published schema types the field `["number", "null"]` for exactly
this reason. The walk below shows both answers, one after the other.

## Deliver it, when it moves

`POST /monitoring/{id}/webhooks` registers an address. Craton POSTs to it when
the measurement of that record **moves**, and never otherwise.

Four fields decide that, and nothing else does: the **feed id**, the
**vintage** read, the **index value**, and the **distance to attachment**.
Those four are the whole of what a delivery says — the payload carries them as
they are now, the same four as they were, and which of them changed. It carries
no attachment flag, no payout ratio, no limit and **no amount of money in any
currency**. If you need to know whether the attachment was reached, read
`distance_to_attachment`: zero or negative is at or past it. Craton states the
distance; what follows from it is yours to decide, outside Craton, exactly as it
was when you executed the transaction.

Every measurement on these two routes — the baseline in the 201 body, each
entry of the list, and both readings inside a delivery — names the `model` that
computed it, name and version. Every result Craton produces carries what produced
it, and a level is a result wherever you read it. The label is not one of the
four compared fields: a model version that moves while all four hold produces
the same level from newer code, so the label is brought up to date on your next
read and nothing is delivered. Each reading carries its own, so
`previous_measurement` is attributed to whatever computed it rather than to
today's version.

Three things about this are limitations rather than features, and they are on
the registration document itself so you never have to infer them:

* **Registering never delivers.** The registration is baselined on a
  measurement taken at that moment, which is in the 201 body. The first
  delivery it ever makes is a genuine change.
* **A delivery arrives on your next read, not the instant a snapshot lands.**
  Every feed here is a pinned snapshot and there is no scheduler in this build,
  so the comparison happens where the recomputation already happens: a read of
  the record (`GET /monitoring/{id}`, `GET /monitoring`, or the webhook list
  itself). An account that never reads its own record is never delivered to.
  `triggers.evaluated_on` says this in the response.
* **At least once, never exactly once.** Every message carries a
  `notification_id` that is the same on every attempt of it — deduplicate on
  that. An attempt is one POST, no redirect followed, two-second timeout; a
  failure is retried on your next read, three attempts in all, after which the
  message is `abandoned` and stays in your delivery log with the reason it
  failed. Nothing of your response is stored or served beyond its status code
  and its size.

`GET /monitoring/{id}/webhooks` is where you read all of that back: every
registration, the measurement it currently stands on, every notification it has
created, and one delivery-log entry per attempt. **That log is the point** — a
delivery that never arrived is visible there rather than silently absent.

The address must be `http` or `https`, must name a host, and must carry no
credentials in it; anything else is refused at registration rather than dialled
and logged as a failure. There is no request signing on this build: if you need
to tell a Craton delivery from any other POST, answer on a path only you know and
check the `Cede-Notification-Id` header.

---

## Walk it

The block records two structures: one written for a term the pinned catalogue
covers in full, and one written for a term ahead of it. It prints the
measurement for each, then reads the whole watch list back.

<!-- cede:runnable -->

```bash
set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see the quickstart}"
: "${CEDE_API_KEY:?export CEDE_API_KEY first — see the quickstart}"

auth="Authorization: Bearer $CEDE_API_KEY"

field() {
  python3 -c 'import json, sys
document = json.load(sys.stdin)
for step in sys.argv[1].split("."):
    document = document[int(step)] if step.isdigit() else document[step]
print(document)' "$1"
}

# 1 — A complete parametric object: exposure, the peril box, limit and
#     attachment, the index that decides the payout, and the term. The term is
#     what decides which days a measurement can see, so it is written twice
#     below: once inside the pinned record, once ahead of it.
cat > in-force-object.json <<'JSON'
{
  "schema_version": "0.1.0",
  "status": "analysed",
  "exposure": {
    "kind": "location_schedule",
    "currency": "JPY",
    "locations": [
      { "ref": "LOC-0001",
        "address_as_given": "2-16-1 Konan, Minato-ku, Tokyo 108-0075",
        "latitude": 35.6284, "longitude": 139.7387,
        "geocode": { "resolution": "rooftop", "confidence": 0.96 },
        "occupancy": "warehouse", "construction": "reinforced_concrete",
        "year_built": 2011,
        "values": { "building": { "amount": 8400000000, "currency": "JPY" } } }
    ],
    "source_fidelity": { "unmapped_columns": [], "guessed_units": [], "ambiguous_rows": [] }
  },
  "peril": {
    "code": "earthquake",
    "region": {
      "description": "Kanto cat-in-a-box: 34.9-36.2N, 139.0-140.6E.",
      "bounding_geometry": {
        "type": "Polygon",
        "coordinates": [[[139.0, 34.9], [140.6, 34.9], [140.6, 36.2],
                         [139.0, 36.2], [139.0, 34.9]]]
      }
    }
  },
  "financial_structure": {
    "limit": { "amount": 2000000000, "currency": "JPY" },
    "attachment": { "value": 6.0, "unit": "M", "index_ref": "kanto-eq-box-magnitude" }
  },
  "trigger": {
    "type": "parametric_cat_in_a_box",
    "index": {
      "name": "kanto-eq-box-magnitude",
      "version": "1.0.0",
      "description": "Largest catalogue magnitude inside the Kanto box, shallower than the depth threshold.",
      "measurement": { "variable": "catalogue_preferred_magnitude", "unit": "M", "statistic": "max" },
      "aggregation_window": { "duration": "PT72H", "alignment": "event" },
      "thresholds": [
        { "label": "attachment", "level": 6.0, "unit": "M" },
        { "label": "exhaustion", "level": 7.0, "unit": "M" },
        { "label": "max_focal_depth", "level": 100, "unit": "km" }
      ],
      "payout_function": {
        "type": "step",
        "points": [ { "level": 6.0, "payout_ratio": 0.25 },
                    { "level": 6.5, "payout_ratio": 0.5 },
                    { "level": 7.0, "payout_ratio": 1 } ],
        "maximum_payout_ratio": 1
      }
    },
    "data_sources": [
      { "id": "usgs-eq-kanto",
        "name": "USGS ANSS Comprehensive Earthquake Catalog - Kanto extract",
        "kind": "quake_catalogue",
        "version": "2026-08-10", "vintage": "2026-08-10" }
    ]
  },
  "period": {
    "inception": "2011-01-01T00:00:00+09:00",
    "expiry": "2012-01-01T00:00:00+09:00",
    "timezone": "Asia/Tokyo"
  }
}
JSON

object_id=$(curl -sS --fail-with-body \
  -H "$auth" -H "Content-Type: application/json" \
  --data-binary @in-force-object.json \
  "$CEDE_BASE_URL/objects" | field id)

structure_id=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL/objects/$object_id/structures" | field id)
echo "structure inside the record: $structure_id"

# 2 — Record it as in force. The body is optional; `reference` is your own
#     identifier for your own transaction, echoed back and read into nothing.
curl -sS --fail-with-body -X POST -H "$auth" \
  -H "Content-Type: application/json" \
  -d '{"reference": "your-own-reference-0001"}' \
  "$CEDE_BASE_URL/structures/$structure_id/monitoring" > recorded.json

python3 -c 'import json
record = json.load(open("recorded.json"))
print("record   :", record["id"], "-", record["status"])
print("asserted by:", record["in_force"]["asserted_by"])
print()
print(record["in_force"]["assertion"])
print()
observation = record["observation"]
model = observation["model"]
print("computed by:", model["name"], "version", model["version"])
feed = observation["feed"]
print("read from:", feed["id"], "vintage", feed["vintage"],
      "(the version the structure pins:",
      feed["vintage_selection"]["is_the_pinned_version"], ")")
window = observation["measurement_window"]
print("measured :", window.get("measured_from"), "to", window.get("measured_to"),
      "-", window["days_measured"], "day(s) of",
      window["cover_period"]["days"], "in the term")
print("index    :", observation["index_value"], observation["index_unit"])
print("attaches :", observation["attachment_level"], observation["index_unit"])
print("distance :", observation["distance_to_attachment"])
print("reached  :", observation["attachment_reached"])
for level in observation["trigger_levels"]:
    print("   level %-5s reached=%-5s distance=%s"
          % (level["level"], level["reached"], level["distance"]))
for occurrence in observation.get("occurrences_at_or_above_attachment", []):
    print("   M%s on %s" % (occurrence["magnitude"], occurrence["time"]))
print()
print(observation["disclosure"])'

# 3 — The same object, written for a term ahead of the pinned record.
python3 -c 'import json
document = json.load(open("in-force-object.json"))
document["period"] = {"inception": "2026-04-01T00:00:00+09:00",
                      "expiry": "2027-04-01T00:00:00+09:00",
                      "timezone": "Asia/Tokyo"}
json.dump(document, open("ahead-object.json", "w"))'

ahead_object=$(curl -sS --fail-with-body \
  -H "$auth" -H "Content-Type: application/json" \
  --data-binary @ahead-object.json \
  "$CEDE_BASE_URL/objects" | field id)

ahead_structure=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL/objects/$ahead_object/structures" | field id)

curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL/structures/$ahead_structure/monitoring" > ahead.json

python3 -c 'import json
observation = json.load(open("ahead.json"))["observation"]
print()
print("a term ahead of the record:")
print("index    :", observation["index_value"], "(null is not zero)")
print("distance :", observation["distance_to_attachment"])
print("why      :", observation["unmeasured_reason"])'

# 4 — Everything this account watches, each measured again as it is listed.
curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/monitoring" > watching.json

python3 -c 'import json
page = json.load(open("watching.json"))
print()
print("watching:", page["count"], "structure(s)")
for record in page["monitoring"]:
    observation = record["observation"]
    print("  %s  index=%-6s distance=%-6s read from %s@%s"
          % (record["structure_id"], observation["index_value"],
             observation["distance_to_attachment"],
             observation["feed"]["id"], observation["feed"]["version"]))'

# 5 — One record at its own address. Measured again: the observed_at moves,
#     and nothing about the measurement is served from storage.
address=$(field links.self < recorded.json)
curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL$address" > again.json

python3 -c 'import json, sys
first = json.load(open("recorded.json"))["observation"]
again = json.load(open("again.json"))["observation"]
print()
print("first read :", first["observed_at"])
print("second read:", again["observed_at"])
print("same level :", first["index_value"] == again["index_value"])
sys.exit(0 if first["index_value"] == again["index_value"] else 1)'

# 6 — Have it delivered when it moves. The registration is baselined on a
#     measurement taken right now, so this call never delivers: what you get
#     back is where the record stands, and the first delivery this webhook
#     ever makes is a genuine change.
monitoring_id=$(field id < recorded.json)

curl -sS --fail-with-body -X POST -H "$auth" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://receiver.example.com/cede/monitoring"}' \
  "$CEDE_BASE_URL/monitoring/$monitoring_id/webhooks" > webhook.json

python3 -c 'import json
registration = json.load(open("webhook.json"))
print()
print("webhook  :", registration["id"], "-", registration["status"])
print("delivers to:", registration["url"])
print("compares :", ", ".join(registration["triggers"]["compared_fields"]))
measurement = registration["measurement"]
print("baseline :", measurement["feed_id"], "@", measurement["vintage"],
      "index", measurement["index_value"], measurement["index_unit"],
      "- distance", measurement["distance_to_attachment"])
print("computed by:", measurement["model"]["name"],
      measurement["model"]["version"])
delivery = registration["delivery"]
print("guarantee:", delivery["guarantee"], "-", delivery["maximum_attempts"],
      "attempt(s),", delivery["timeout_seconds"], "s each")
print()
print(registration["triggers"]["evaluated_on"])
print()
print(registration["disclosure"])'

# 7 — Every webhook on this record, each with its own delivery log. Nothing
#     has moved since step 6, so nothing was created and nothing attempted —
#     which is exactly what an empty log means, and why it is worth reading.
curl -sS --fail-with-body -H "$auth" \
  "$CEDE_BASE_URL/monitoring/$monitoring_id/webhooks" > webhooks.json

python3 -c 'import json
page = json.load(open("webhooks.json"))
print()
print("registered:", page["count"], "webhook(s) on", page["monitoring_id"])
for registration in page["webhooks"]:
    print("  %s -> %s" % (registration["id"], registration["url"]))
    print("     notifications:", len(registration["notifications"]),
          " delivery attempts:", len(registration["delivery_log"]))
    for entry in registration["delivery_log"]:
        print("     attempt %s of %s: %s (%s)"
              % (entry["attempt"], entry["notification_id"],
                 entry["outcome"], entry.get("error",
                                             entry.get("response_status"))))'
```

The last line exits non-zero if two reads of the same structure against the
same vintage ever disagreed about the level. They cannot: the measurement is
arithmetic over pinned bytes, and the only thing that can move it is a new
vintage of the feed — which the response names.

---

## Reading a record

| Member | What it is |
| --- | --- |
| `status` | `recorded`, and that is the only value this build writes. It is the state of Craton's record, never a statement about anything outside Craton — there is no `active` and no `triggered`. |
| `in_force.asserted_by` | `account_holder`. The only truthful value: Craton has one source for the fact and it is you. |
| `in_force.assertion` | The fixed sentence saying what recording is and is not. The same words on every record. |
| `reference` | Your own string, echoed. Craton reads nothing into it and resolves it against nothing. |
| `observation` | The measurement, below. |

## Reading an observation

| Member | What it is |
| --- | --- |
| `model` | The name and version of the model that computed the level. Every result Craton produces is labelled with what produced it, and this is that label — the same pair a price or a backtest of the same structure carries, because it is the same reader. |
| `feed` | The one pinned snapshot the level was computed from: id, version, vintage, the SHA-256 of the exact bytes, the paths to fetch them, and `vintage_selection`. One snapshot per measurement, never a merge. |
| `index_value` | The index level over the measured window, in `index_unit`. `null` when no level could be measured, and then `unmeasured_reason` says which. |
| `attachment_level` | The level at which your payout function first pays. |
| `distance_to_attachment` | `attachment_level` minus `index_value`. Positive is short of the attachment, negative is past it; `distance_basis` says so in the response. |
| `attachment_reached` | Whether the measured level reached the attachment. A statement about a number, and about nothing else. |
| `trigger_levels` | Every level of your payout function, each with `reached` and `distance`. The `payout_ratio` on each is echoed from your own structure — it is not multiplied by anything here. |
| `measurement_window` | Which days were measured, which were not, and where the record ends. |
| `method` | How the level was computed, named and described, so you can reproduce it from the same snapshot. |
| `disclosure` | The fixed sentence: a measurement, not a determination. |
| `limitations` | What the measurement does not account for — snapshot vintages are revised, and the index is not the loss. |

A catalogue-based index also carries `occurrences_considered`,
`occurrences_at_or_above_attachment` and `largest_occurrence`. The full
selection behind them is [the event catalogue](events.md)'s job, one call away.

## Reading a webhook registration

| Member | What it is |
| --- | --- |
| `url` | The address deliveries are POSTed to, echoed exactly as you registered it. |
| `status` | `registered`, and that is the only value this build writes. A state of Craton's record, never a statement about anything outside it. |
| `triggers.compared_fields` | The four fields a change in which creates a notification. Nothing outside this list is compared. |
| `triggers.evaluated_on` | Where the comparison happens — a read of the record — and therefore when a delivery can reach you. |
| `measurement` | The four fields this registration currently stands on: `feed_id`, `vintage`, `index_value`, `distance_to_attachment` — plus the `index_unit` they are read in and the `model` (name and version) that produced them, neither of which is compared. Every result Craton produces carries what produced it, and a level served here is a result like any other; if a model version moves while all four fields hold, the label is brought up to date on the next read and nothing is delivered, because nothing moved. |
| `delivery` | The promise: `at_least_once`, how many attempts one message gets, and how long each may take. |
| `notifications` | Every message this registration has created, oldest first, each `pending`, `delivered` or `abandoned`, with the four fields before and after and which of them `changed`. |
| `delivery_log` | One entry per attempt, newest first: the notification, the attempt number, the outcome, the receiver's status code if it answered, and the error if it did not. Capped at `delivery_log_limit`. |
| `disclosure` | The fixed sentence: a measurement that moved, delivered. Not a notice under any contract. |

## Reading a notification

This is the document Craton POSTs to your address, published as
[`monitoring-notification.v0.schema.json`](/monitoring-notification.v0.schema.json)
so you can validate it holding no Craton code.

| Member | What it is |
| --- | --- |
| `type` | `measurement_changed`, the only kind this build sends. The name is the whole of what it asserts. |
| `notification_id` | Stable across every attempt. Deduplicate on it — delivery is at least once. |
| `model` | The name and version of the model that computed the level in `measurement`. Every result Craton produces carries what produced it. |
| `changed` | Which of the four fields moved. |
| `measurement` / `previous_measurement` | The four fields now, and the four fields before, so a receiver holding nothing can still see the move. Each carries its own `model`: the earlier reading is attributed to whatever computed it, which need not be the version named at the top of the message. |
| `links.monitoring` | Where you read the full observation back — the window, the levels, the method and the limitations. The message carries the difference; the record carries the measurement in full. |

There is no attachment flag on a notification and no amount of money in one.

The response shapes are published as
[`monitoring.v0.schema.json`](/monitoring.v0.schema.json),
[`monitoring-index.v0.schema.json`](/monitoring-index.v0.schema.json),
[`monitoring-webhook.v0.schema.json`](/monitoring-webhook.v0.schema.json) and
[`monitoring-webhook-index.v0.schema.json`](/monitoring-webhook-index.v0.schema.json); the map
from every route to the schema governing its responses is
[`api-contract.v0.json`](/api-contract.v0.json).

## When it refuses

Every refusal is the standard error envelope ([error reference](errors.md)).

| What happened | Answer |
| --- | --- |
| The structure id is not one of yours, or does not exist | `404 not_found`. Another account's structure is not found, not forbidden: "it exists but is not yours" is a disclosure of its own. |
| The body carries a member this endpoint does not read | `422 invalid_request`, naming the members it does read. A member answered as though it had been stored is a member you would believe Craton holds. |
| `reference` is not a non-empty string, or is longer than 256 characters | `422 invalid_request`. |
| The structure has no trigger, no term, names two data sources, or names a feed nothing is pinned for | `422 unmonitorable_structure`, naming what is missing and — for a feed — what is pinned. |
| The monitoring id is not one of yours, or does not exist | `404 not_found`. |
| A webhook body carries a member this endpoint does not read | `422 invalid_request`. It reads `url`, and nothing else. |
| `url` is missing, empty, longer than 2048 characters, names a scheme other than `http` or `https`, names no host, or carries credentials in it | `422 invalid_request`, naming which. Refused at registration rather than dialled and logged as a failure. |

## What is not here

* **Stopping.** There is no way to stop watching a structure, or to remove a
  webhook registration, on this build. A registration whose receiver is gone
  abandons its messages and keeps saying so in the delivery log.
* **A scheduler.** Nothing recomputes in the background, so a delivery reaches
  you on your next read of the record rather than the instant a newer snapshot
  lands. See [deliver it, when it moves](#deliver-it-when-it-moves).
* **Request signing.** Deliveries are not signed on this build. Answer on a
  path only you know, and check the `Cede-Notification-Id` header.
* **A history of observations.** Every read measures now; nothing keeps a
  series of past readings.
* **Anything after the measurement.** Craton reports what it measured. Everything
  that follows from a level being reached happens entirely outside Craton, as it
  did when the transaction was executed.
