# The CEDE canonical risk object schema

This directory is the open-source half of Cede (BRIEF.md: "The schema is open
source; the engine, normalisation and hosted verbs are paid"; SPEC.md §5,
surface 4). Everything here is consumable without a Cede account and without
installing anything beyond a `python3`.

```
schema/
  risk-object.v0.schema.json     the schema — JSON Schema draft 2020-12
  price-response.v0.schema.json  what the Price verb returns (SPEC §3.3)
  structure.v0.schema.json       a candidate structure (SPEC §3.4)
  backtest-response.v0.schema.json  what the Backtest verb returns (SPEC §3.5)
  package.v0.schema.json         the submission pack the Package verb assembles (SPEC §3.7)
  job.v0.schema.json             the uniform job resource every verb uses (SPEC §3)
  error.v0.schema.json           every non-2xx response the API returns
  service-info.v0.schema.json    the body of GET /health
  account.v0.schema.json         an account and, once, its API key (SPEC §1, §6)
  api-contract.v0.json           which schema governs which response (SPEC §7)
  validate.py                    stdlib-only validator + example suite runner
  examples/
    expectations.json            what the suite asserts, and why
    valid/                       objects that must be accepted
    invalid/                     objects that must be rejected, with reasons
```

Everything below concerns the risk object. The five response schemas, and the
contract that says which of them governs which response, have their own
sections at the end.

Run everything:

```
python3 schema/validate.py             # the committed example suite; exit 0 = green
python3 schema/validate.py --check-schema
python3 schema/validate.py --self-test # differential sweep (needs `jsonschema`)
python3 schema/validate.py path/to/object.json [...]
```

## What is normative

SPEC.md §2 defines the *semantics* of every field and is normative. This schema
encodes those semantics as constraints. **Where the two disagree, SPEC.md wins
and this schema has a bug** (SPEC.md §2, "Normative source split"). Nothing in
this directory may be read as amending SPEC.md.

`schema_version` is on every object. This file is the `0.x` line; a breaking
change bumps the major version and lands as a new file, never as an edit to
this one.

## Required and optional, and where each comes from

SPEC.md §2 does not carry a required/optional column, so every choice below is
derived from a sentence in SPEC and cited. Anything SPEC leaves open is left
open here too, deliberately — inventing an answer would be a silent spec
amendment.

### Top level

| Field | Required? | Derivation |
|---|---|---|
| `schema_version` | **always** | §2: "Schema versions are explicit (`schema_version` on every object)". |
| `status` | **always** | §2 lists it as the lifecycle of the object; every verb branches on it. Enum closed to `draft`, `analysed`, `priced`, `recorded_in_force` (SPEC's own examples); new values arrive by version bump. |
| `exposure` | **always** | §3.1: Ingest emits canonical objects "with `exposure` populated". An object with nothing at risk describes nothing. |
| `peril` | when `status` is `analysed`, `priced` or `recorded_in_force` | §3.2: Analyse does hazard lookups and event footprints — impossible without `peril.code`. |
| `financial_structure` (with `premium`) | when `status` is `priced` or `recorded_in_force` | §3.3: Price writes the technical price into `financial_structure.premium`. |
| `trigger`, `period` | when `status` is `recorded_in_force` | §3.5 takes "a structure with a fully specified trigger"; §3.6 monitors a recorded structure against the live feeds behind that trigger, over a period. |
| `jurisdiction` | never forced | §2 calls it informational to Cede. Nothing in SPEC makes an object invalid without it. |
| `counterparties` | never forced | §2: "Descriptive only — recording a party on an object creates no obligation". SPEC's open question about whether counterparties are first-class resources is left open. |
| `id` | never forced | §2: "Server-assigned identity". There is a moment before assignment; a candidate object that has not been persisted has no id. Every stored object carries one. |
| `provenance` | never forced | §2 describes its content, not its presence. An object authored directly through the API has no ingest run behind it. |

The status-conditional rules are the four `allOf` entries in the schema, each
carrying its own `title` and `description` naming the SPEC section it comes
from. They only ever *add* `required` names — no branch relaxes anything.

### Inside the blocks

Required within a block whenever that block is present:

- **exposure**: `kind`, and `source_fidelity` with all three of its lists
  present. §2 calls `source_fidelity` the honesty channel: what Ingest could not
  confidently normalise is "never silently dropped". Mandatory empty lists mean
  "nothing was guessed" has to be *stated*, so an object cannot look clean by
  omission. `locations` is optional — SPEC's open question ("how index/revenue
  exposures without locations are validated") is genuinely open, and closing it
  here would be an invention. A fourth list, `inferred_mappings`, is optional
  and appears only when a column's field was *inferred* rather than read from
  the source's own vocabulary — today by the AI-assisted mapping pass over the
  columns the deterministic mapper left unmapped (`adr/ADR-0016`,
  `adr/ADR-0017`). Each entry names the column, the field adopted (a closed
  enum of the mapper's own vocabulary), and the model and version that
  proposed it. There is deliberately no free-text member: no model-composed
  string may enter a response field, and a note field would be the one hole
  that rule closes. `provenance.assist_run` records the same run once, with a
  digest of everything adopted, and that digest is carried in the ingest run
  id so an assisted run cannot be mistaken for the deterministic run over the
  same bytes.
- **exposure.locations[]**: `geocode` only, plus an `anyOf` requiring either
  both coordinates or `address_as_given` — a location must say *where* in one
  of the two ways a source file can say it, and always states which via
  `geocode.resolution` (`as_supplied` for coordinates the file supplied,
  `unknown` for an address nobody has resolved). Coordinates are deliberately
  not required: SPEC §3.1 puts enrichment out of Ingest's scope, so Ingest can
  never manufacture them, and requiring them would make the ordinary
  address-only SOV unrecordable as anything but an aggregate. See
  `adr/ADR-0006-ungeocoded-locations.md` — that ADR also records why this is a
  schema bug fix rather than a SPEC amendment.
- **peril**: `code` only. The taxonomy is the eight codes SPEC names and is
  closed: it "grows only via schema version bump". `tropical_cyclone` is the
  code for named-storm structures.
- **financial_structure**: `limit` and `attachment` — SPEC's defining pair.
  `attachment` is `oneOf` a money amount or an index level, and a `wording`
  trigger forces the money form (§2 cross-references the trigger for exactly
  this). `premium`, `layers` and `reinstatements` are outputs of Price and
  Structure, so they are optional until those verbs have run.
- **financial_structure.premium**: `basis` (constant `technical`), `amount`,
  `currency`, `assumptions_ref`. §2 makes the assumptions pointer mandatory in
  as many words; the constant `basis` encodes §4 P3 ("every price output is
  labelled technical") so the label cannot be dropped or relabelled by anything
  that validates against this schema.
- **trigger**: `type`, plus exactly one populated branch — `index` +
  `data_sources` for the parametric types, `wording_ref` for `wording`. §2:
  "Exactly one of the two branches is populated." Each data source is pinned to
  a version, per §2's "each pinned to a version/vintage".
- **trigger.index**: `name`, `version`, `measurement`, `aggregation_window`,
  `thresholds`, `payout_function` — §2's own list of what an index definition
  is, held as versioned code.
- **period**: all three of `inception`, `expiry`, `timezone`.
- **jurisdiction**: `governing_law`. `territories` is optional.
- **counterparties[]**: `role` and `name`.
- **provenance.model_runs[]**: `model` and `model_version` on every entry —
  §4 P9 requires every result to be labelled with the model and version that
  produced it.

`additionalProperties: false` holds everywhere, so a misspelled field is an
error rather than silent data loss. Namespaced peril packs (SPEC §2A) will need
an extension point; adding one is a version bump, and it is listed as an open
question below rather than pre-built here.

## What the validator does that JSON Schema cannot

`validate.py` applies two rules SPEC states and JSON Schema has no keyword for:

- **S1** — `period.expiry` must be strictly after `period.inception` (§2,
  period). No JSON Schema keyword compares two sibling instance values.
- **S2** — `period.timezone` must name a real IANA zone. Checked against the
  machine's tz database via `zoneinfo`; if there is no tz database, the
  validator says so out loud instead of passing over it.

Three further honesty properties, in the spirit of `checks/perimeter.sh`:

1. **No silent under-validation.** The validator refuses to load a schema
   containing a keyword it does not implement. A validator that ignores unknown
   keywords silently validates less than the schema says.
2. **The invalid examples pin their reason.** `expectations.json` records the
   error substring each rejection must produce, so a file cannot pass by being
   rejected for an unrelated defect.
3. **Cross-check.** When the `jsonschema` package is importable, every verdict
   is computed twice — once by `validate.py`, once by that reference
   implementation — and a disagreement fails the run. When it is absent, the
   run says so; it does not pretend the metaschema was consulted.
4. **Differential self-test.** `--self-test` mutates every node of every valid
   example nine ways — delete it, retype it, negate it, duplicate a list item —
   and requires the two implementations to agree on all 2277 mutants. Run
   against a deliberately blinded copy of `validate.py` (one keyword's
   enforcement removed) it goes RED, which is the property that makes a green
   run worth anything. It needs `jsonschema` to disagree with, so where that
   package is absent it prints SELF-TEST SKIPPED and is not counted as a pass.

## Open questions carried from SPEC.md §2

Recorded, not answered — each needs a spec amendment (SPEC §8) or a planner
decision before it can be encoded:

- Minimum viable location attribute set for the parametric wedge, and how
  index/revenue exposures without locations are validated.
- Whether a multi-peril structure is one object or a set of linked
  single-peril objects; alignment of the peril taxonomy with any external
  standard.
- Aggregate vs occurrence limits; franchise vs deductible semantics; premium
  adjustability features.
- How index code is sandboxed and reproduced; a basis-risk metadata standard.
- Multi-year and seasonal (wind-season-only) periods.
- Whether regulatory-class metadata belongs on the object at all.
- Whether counterparties are first-class API resources shared across objects
  or embedded per object, as they are here.
- The extension point peril packs (SPEC §2A) will need, given
  `additionalProperties: false`.

Two more raised by writing this file:

- `id` and `provenance` are optional here (see the table). If the platform
  decides a stored object must always carry both, that is a tightening and
  wants a planner decision recorded before the schema changes.
- `status` is a closed enum built from SPEC's own "e.g." list. If a lifecycle
  state is missing, it arrives by version bump, not by opening the enum.

## `price-response.v0.schema.json` — what Price returns

Published for the same reason the risk object is: a consumer must be able to
validate what Cede returns without holding any Cede code, and the contract
oracle (SPEC §7) validates every response against **this same file**, not
against a private copy.

SPEC §3.3 is normative. Its load-bearing clause is not the premium but the
assumption set — "expected loss, loading components, technical premium, and the
**complete assumption set** (model versions, data vintages, event set,
loadings, currency basis) as first-class response fields, not footnotes" — so
`assumptions` requires all ten of its blocks:

| Block | What it answers |
|---|---|
| `data_sources` | which pinned public snapshot was read, at which vintage, with the sha256 of the exact bytes |
| `period_of_record` | over what history the burn was computed |
| `method` | the burn/frequency method by name, its occurrence basis, its clustering rule, and the precision every ratio is rounded to |
| `event_selection` | the box, the thresholds, the counts, and every occurrence that would have paid — listed, not sampled |
| `frequency` | empirical occurrence frequency and return period at each band, `null` where a band was never observed |
| `burn` | the burn rate, its standard deviation, and the year-by-year working including the zero years |
| `loadings` | each loading with its rate, its base, its amount and its reason |
| `cover_period` | the period priced and how the annual burn was scaled to it |
| `currency_basis` | currency, minor units, rounding, and whether any rate was applied |
| `limitations` | what the price does not account for, in plain sentences — required non-empty |

Two constraints in this file are perimeter rules (SPEC §4, P3) rather than
data-modelling choices, and both are `const`:

- `technical_price.basis` is the constant `technical`;
- `technical_price.disclosure` is a **fixed sentence** stating that the output
  is not a price at which any party stands ready to transact and carries no
  capacity, no acceptance and no validity period.

A constant, not a free string, is the point: free text can be reworded into an
implication of transactability one careless edit at a time. `tests/contract/`
asserts both constants against the schema itself, so relaxing either fails the
contract gate rather than passing quietly.

Validate a response with any draft 2020-12 implementation, e.g.:

```
uv run python -m cede.price tests/fixtures/price/kanto-eq-cat-in-a-box.object.json
```

and check the output against this file.

## `job.v0.schema.json` — the uniform job resource

SPEC §3 fixes one asynchronous pattern for all seven verbs: "`POST` creates a
job, `GET /jobs/{id}` polls, result embeds or links the produced objects". One
schema covers every verb's job, so a client that can poll Ingest can poll Price
without learning a second protocol, and the `verb` enum grows by version bump
as the remaining verbs land — a job can never name a verb this file has not
documented.

Three conditionals carry the states, and they are constraints rather than
conventions:

- `succeeded` requires `result` and forbids `error`;
- `failed` requires `error` and forbids `result`;
- `queued` and `running` forbid both — an absent result is never an empty one.

`result.objects` is always present and always a list, empty included, so a
client never has to tell "no objects" from "field missing". Ingest links its
produced objects; Price embeds the whole price response document, because a
technical price apart from its assumption set is a number without a meaning
(SPEC §3.3).

`subject.source.media_type` is optional for a reason worth stating, because
it is the difference between a claim and a finding: it is what the *upload
declared*, checked against what Ingest reads before the job was created, and
it is **absent** when the caller declared nothing or declared
`application/octet-stream` — "these are bytes, kind unknown", which is the
absence of a claim rather than a claim of opacity. What the reader actually
read the file as is a separate statement made after the bytes were seen, and
lives on the produced object at `provenance.source_files[].media_type`.

Nothing in the vocabulary describes a transaction. Job states describe
computation and nothing else; there is no terminal state that means anything
was executed (SPEC §4).

## `error.v0.schema.json` — every refusal

SPEC §3 requires every response to validate against the published schema, and
an error is a response: a client that cannot parse a 401 has not integrated.
So refusals are published rather than left in whatever shape a web framework
defaults to — including the ones the framework raises before any handler runs
(unknown path, wrong method, unreadable body).

Three fields, `status`, `code` and `message`, plus optional `details` for a
request that failed several ways at once. `code` is a *pattern*, not an enum,
deliberately: a new failure mode must never need a schema version bump before
it can be reported honestly, and a client switches on the codes it knows and
falls back to `status` for the rest.

A pattern is not an excuse for an undocumented surface, so the codes this build
*does* report are published too — `errors.named_codes` in
`api-contract.v0.json`, each with the statuses it arrives with, generated from
the server like the rest of that file. `docs/errors.md` is the same catalogue
as prose, with the fallback codes (`client_error`, `server_error`) an unnamed
status reports as, the shape a failure takes on a job, and the compatibility
rules a client can rely on.

A refusal is a fact about a request. It is never a statement about risk
appetite and never a commercial position of any kind — Ingest classifies data,
never risk (SPEC §3.1).

## `api-contract.v0.json` — which schema governs which response

Not a JSON Schema: the map *between* them. Publishing five response schemas
without saying which one applies where leaves the last step to the reader, and
a reader who guesses wrong validates a job against the risk object schema and
concludes Cede is broken. So the map is published too, and it is the same map
the contract oracle (SPEC §7) validates against — there is no private copy.

```json
{
  "routes": [{ "method": "POST", "path": "/ingest", "answers": { "202": "job" } }],
  "errors": { "schema": "error", "named_statuses": [400, 401, 404, 405, 413, 415, 422, 500] },
  "embedded": [{ "in": "job", "pointer": "/result/price", "schema": "price-response" }],
  "schemas": { "job": "job.v0.schema.json", "...": "..." }
}
```

Read it as four rules:

- **`routes`** — for a 2xx, take `answers[status]` and validate the body
  against `schemas[that id]`. A status not listed is a response Cede does not
  intend to return, and the oracle fails on it rather than passing it.
- **`errors`** — *any* status of 400 or above, from any route, validates
  against `error.v0.schema.json`. One error shape for the whole surface, so a
  client writes the refusal path once. `named_statuses` lists the refusals this
  build names a code for; it is not a claim that no other status can occur.
- **`embedded`** — a published document carried inside another one, at a JSON
  pointer. Today there is one: the price response embedded in a succeeded price
  job, which `job.v0.schema.json` deliberately restates loosely so the two
  files cannot drift into disagreeing about the same bytes. Follow the pointer
  and validate it in full.
- **`versions.emitted`** — the `schema_version` this build stamps on documents
  of each schema. The names in `schemas` are version *lines*, not versions:
  `risk-object.v0.schema.json` admits every `0.x.y` document, because a minor
  version that only adds optional members must not retroactively invalidate an
  object a consumer already holds. So the line is read from `schemas` and the
  version this deployment actually emits from here — the one question the
  files alone cannot answer. Generated from the same constants the responses
  are built from (`cede.api.contract.schema_versions`), never typed, and
  `tests/unit/test_schema_channel.py` asserts every entry against the module
  constant it mirrors.

The file is **generated from the server's own route registrations**
(`uv run python -m cede.api.contract --write`), never hand-edited: routes are
registered through a contract registry that will not register a route without
the published schema of every answer, and the contract suite fails if this file
and the running surface disagree in either direction. A route the map does not
document is a red gate, not an undocumented feature.

## `service-info.v0.schema.json` — the body of `GET /health`

One of the two routes the API serves without a key, so a deploy, a load
balancer and the PROD-09 rollback drill can ask whether a build is serving
without holding one. It publishes no product data. `service` and `status` are
constants, so a misrouted health check fails loudly instead of passing against
something else, and `verbs` lists what this build actually serves —
measurement, not marketing: it is how a client discovers that Analyse is not
here yet.

## `account.v0.schema.json` — an account, and once, its key

The other route served without a key, because it is the one that hands the
first key out. `POST /signup` creates an account and hands back its API key,
with nobody's involvement but the caller's; `GET /account` reads back the
account the presented key belongs to. Both answer with this schema — the same
document, except that signup carries the `api_key` member and nothing else
ever does.

```
curl -sS -X POST https://<host>/signup -H 'content-type: application/json' \
     -d '{"label":"my integration"}'
```

```json
{
  "schema_version": "0.1.0",
  "id": "acct_0123456789abcdef01234567",
  "created_at": "2026-08-10T12:00:00.000000Z",
  "plan": "sandbox",
  "label": "my integration",
  "api_key": {
    "id": "key_0123456789abcdef",
    "secret": "cede_sk_EXAMPLE-value-shown-here-is-not-a-real-key",
    "created_at": "2026-08-10T12:00:00.000000Z",
    "scheme": "Bearer",
    "expires_at": null
  }
}
```

Then every other route reads `Authorization: Bearer <secret>`.

Four things this schema is stating, not implying:

- **The key does not expire.** `api_key.expires_at` is required and is `null`,
  which is the only value this build issues: no time-to-live, no session, no
  refresh call. A `401` on a key that worked earlier is about the credential
  that arrived, never about how long ago it was issued.
- **The secret is returned once.** Cede keeps its SHA-256 digest and nothing
  else, so no later call, no operator and no database dump can recover the
  secret. Store it when you receive it; if it is lost, sign up again.
- **`id` and `api_key.id` are public.** They own the objects and jobs the key
  creates and they appear in logs. Neither reveals the secret — the key id is a
  digest prefix, a one-way function of it.
- **`plan` is an enum of one.** Every self-service account starts on SPEC §6's
  free sandbox, which is sufficient to integrate against without payment.
  Further plans arrive by a schema version bump, never by a silent widening.
