# Exposure coding: OED codes for messy rows

`POST /exposure-codes` takes the rows of a schedule of values as plain text,
one building a row, and answers with OED 5.0.0 `OccupancyCode` and
`ConstructionCode` values for each. Every field comes back with its
confidence and whether it cleared the gate. A field below the gate is not
coded: its row is marked for a person to look at, with the proposal beside it.

Read [the quickstart](quickstart.md) first for the two values you export
(`CEDE_BASE_URL`, `CEDE_API_KEY`) and how to get a key. The same thing is on
the website as a tool you can paste a schedule into: [the SOV
coder](https://oncraton.com/sov-coder/).

## What it is for, and what it is not

A schedule says "2 sty wd frm apts" or "tilt-up whse" or "RC office tower".
A catastrophe model reads `1052` and `5050`. This route does that translation
for the two fields every model needs first, and tells you how sure it is.

* **Not a silent step.** Nothing is written onto any object, and Ingest does
  not call it. You get codes and confidences; what you do with them is yours.
* **Not a chat.** You send rows. The questions each row is asked are fixed by
  Craton and published by digest in every answer; a request that carries
  anything but `rows` is refused.
* **Not a guess.** A code appears only when the model's answer maps to a real
  OED code and its confidence clears the gate. OED's own Unknown codes (1000
  and 5000) are never written as codes: "the row does not say" is left for you.

## Who does the coding

Each row is read by **Jev**, a decision model made by TypeSafe, reached
through **Vercel's AI Gateway** (`typesafe-ai/jev`). Jev does not write text.
It picks one answer from a closed list of classes Craton gives it, each
mapped to one OED code, and says how confident it is. Craton's own code then decides what becomes a code.
Every answer names the model, the provider that served it, and the digest of
the question set, so you can tell which questions produced which codes.

## The request

```json
{
  "rows": [
    "Apartments, 3 sty wood frame, built 1986, Houston TX",
    "Distribution whse, concrete tilt-up, Miami FL",
    "Oficinas, concreto reforzado, 12 pisos, Ciudad de Mexico"
  ]
}
```

| Member | What it is |
| --- | --- |
| `rows` | 1 to 50 strings, one building each, at most 1,000 characters after runs of spaces and line breaks are collapsed. The only member the route reads. |

More rows than that go in more requests. A request over a limit is refused
with `413 payload_too_large`, and a request that is not an object with a
`rows` array of strings, or that carries any other member, with
`422 invalid_request`. Repeated rows in one request are sent once.

## What comes back

```json
{
  "schema_version": "0.1.0",
  "run_id": "exposure-codes-v0:9c0f1a2b3c4d5e6f:0a1b2c3d4e5f6a7b",
  "coded_at": "2026-09-25T15:02:11Z",
  "model": {"name": "typesafe-ai/jev", "served_by": ["typesafe-ai"], "via": "vercel-ai-gateway"},
  "question_set": {"id": "craton-jev-questions/1:v4_final", "version": "2026-09-25T19:16:25Z", "digest": "sha256:..."},
  "oed": {"version": "5.0.0", "fields": ["OccupancyCode", "ConstructionCode"]},
  "gate": {"OccupancyCode": 0.95, "ConstructionCode": 0.85},
  "counts": {"rows": 1, "coded": 0, "needs_review": 1, "unanswered": 0},
  "rows": [
    {
      "row": 1,
      "status": "needs_review",
      "generation_id": "gen_01M3CFPRNSZSWW3J7E255S80TV",
      "fields": {
        "OccupancyCode": {
          "code": 1052, "proposed": 1052,
          "name": "Residential, Permanent dwelling: multi-family",
          "confidence": 0.99, "cleared": true, "why": "cleared", "alternatives": []
        },
        "ConstructionCode": {
          "code": null, "proposed": 5100, "name": "Masonry, Masonry",
          "confidence": 0.62, "cleared": false, "why": "below_gate",
          "alternatives": [{"code": 5105, "name": "Masonry, Reinforced masonry", "probability": 0.21}]
        }
      }
    }
  ]
}
```

That shows the shape, not a recorded answer: the numbers are there to read
the fields by. The [published schema](/schema/exposure-codes.v0.schema.json)
is the whole contract, and it lists every OED code a field can carry.

| Field | What it is |
| --- | --- |
| `run_id` | Changes whenever any row is coded differently, or by another provider or question set. |
| `coded_at` | When, in UTC. The gateway reports no dated model version, so the date is part of the attribution. |
| `model` | The model as the gateway reported it, the providers that served this request, and the route. |
| `question_set` | Which fixed questions every row was asked, by digest. |
| `gate` | The confidence each field needs to be coded without a person looking. |
| `counts` | How many rows came back `coded`, `needs_review` and `unanswered`. |
| `rows[].generation_id` | The AI gateway's id for the call that answered the row (`gen_...`), or `null`. Give it to the provider if you ever need a call looked up. |
| `rows[].status` | `coded`: every field cleared. `needs_review`: at least one did not. `unanswered`: no usable reply came back for the row; send it again. |
| `fields.*.code` | The OED code, or `null` when the field did not clear. Never a code below the gate. |
| `fields.*.proposed` | The code the model's answer maps to, cleared or not. |
| `fields.*.name` | The OED name of `proposed`, from the OED 5.0.0 lists. |
| `fields.*.confidence` | The model's confidence, 0 to 1. |
| `fields.*.why` | `cleared`, `below_gate`, `unknown` (the row does not say; OED Unknown), `unrecognized_answer` or `no_answer`. |
| `fields.*.alternatives` | Up to two other codes the model gave at least a 5% chance, most likely first. |

The row text is not echoed back; rows are numbered from 1 in the order sent.

## Limits, and when it is off

* **Per key:** 20 requests a minute by default, on top of the API's own
  per-key limit. **Per client address:** 120 requests an hour. Over either,
  `429 rate_limited` with `Retry-After`.
* **Pace:** the provider sets it. Craton spaces its calls at about 25 rows a
  minute across everyone using the route, so a request of 50 rows can come
  back with some rows `unanswered`; ten rows a request is quickest.
* **Per day, across all of Craton:** a budget of rows sent to the provider.
  Once it is used, `503 exposure_coding_unavailable` with `Retry-After` until
  midnight UTC.
* **Switched off:** exposure coding is off unless the environment switches it
  on. Off, every well-formed request answers `503 exposure_coding_unavailable`
  and nothing leaves Craton. The same 503 comes back if the provider gave no
  usable answer for any row in a request, with `Retry-After` when the
  provider named a wait. When it answers some rows and not others, the
  answer is a `200` and those rows are `unanswered`: send just them again.

Not metered: nothing is stored and no catastrophe model runs.

## Where your rows go

Coding a row sends its text to Vercel's AI Gateway and on to the provider that
serves Jev (TypeSafe, or a fallback the gateway chooses; the answer names
which). The gateway's listing for this model says the providers do not train
on what they are sent; zero data retention is not available for it, so a provider
may keep the text for a time under its own terms. Those services see the row
text and the Craton server that asked,
never your key, your account or your own address. Craton does not store the
rows, keep them in a cache or write them to its logs.

Send what describes a building: its use, how it is built, its size and age,
its city. Never send a person's name or anything else about a person.

## Where the codes come from

The OED 5.0.0 code lists are the Open Exposure Data standard's, as published
by Oasis LMF (CC0). Craton checks every proposed code against them before it
can appear in an answer.

---

## Walk it

The first block codes three rows and prints what came back. If the
environment you point it at has exposure coding switched off, or the provider
did not answer, it prints the refusal instead, which is the other answer this
route is documented to give.

<!-- 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}"

# 1: three rows of a schedule, US first.
cat > rows.json <<'JSON'
{"rows": [
  "Apartments, 3 sty wood frame, built 1986, Houston TX",
  "Distribution whse, concrete tilt-up, Miami FL",
  "Oficinas, concreto reforzado, 12 pisos, Ciudad de Mexico"
]}
JSON

status=$(curl -sS -o coded.json -w '%{http_code}' \
  "$CEDE_BASE_URL/exposure-codes" \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H 'Content-Type: application/json' \
  --data @rows.json)

if test "$status" = 503; then
  # Coding is off here, or the provider did not answer: the documented refusal.
  python3 -c 'import json
error = json.load(open("coded.json"))["error"]
print(error["status"], error["code"])
print(error["message"])'
else
  python3 -c 'import json
answer = json.load(open("coded.json"))
print("run       :", answer["run_id"])
print("model     :", answer["model"]["name"], "served by", ", ".join(answer["model"]["served_by"]))
print("gate      :", answer["gate"])
for row in answer["rows"]:
    print("row", row["row"], row["status"])
    for field, entry in sorted(row["fields"].items()):
        print("  ", field, entry["code"], "proposed", entry["proposed"], entry["name"], entry["confidence"], entry["why"])'
fi
```

The second block turns an answer into OED-style CSV, leaving a field blank
wherever it did not clear the gate, with a column that says why. It codes the
same rows again, so it stands on its own.

<!-- 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}"

# 2: the same rows, written out as OED-style CSV.
cat > rows.json <<'JSON'
{"rows": [
  "Apartments, 3 sty wood frame, built 1986, Houston TX",
  "Distribution whse, concrete tilt-up, Miami FL",
  "Oficinas, concreto reforzado, 12 pisos, Ciudad de Mexico"
]}
JSON

status=$(curl -sS -o coded.json -w '%{http_code}' \
  "$CEDE_BASE_URL/exposure-codes" \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H 'Content-Type: application/json' \
  --data @rows.json)

if test "$status" = 503; then
  python3 -c 'import json
error = json.load(open("coded.json"))["error"]
print(error["status"], error["code"])'
else
  python3 -c 'import csv, json, sys
rows = json.load(open("rows.json"))["rows"]
answer = json.load(open("coded.json"))
out = csv.writer(sys.stdout)
out.writerow(["LocNumber", "Text", "OccupancyCode", "ConstructionCode", "Status"])
for coded, text in zip(answer["rows"], rows):
    fields = coded["fields"]
    out.writerow([
        coded["row"],
        text,
        fields["OccupancyCode"]["code"] or "",
        fields["ConstructionCode"]["code"] or "",
        coded["status"],
    ])'
fi
```
