# Calchis Determination Standard, version 2

`calchis-trigger-spec/2` · `calchis-determination/2`

**Status: proposed, 2026-10-17.** One implementation exists (this repository).
No standards body has reviewed it, no counterparty has adopted it, and no
offering document references it. Version 1 (`docs/determination-standard-v1.md`)
stands unchanged beside it: every v1 specification and every v1 determination
already issued keeps its identifier and verifies exactly as it did.

---

## 1. Why a second version

Version 1 expresses one comparison: *any single observation of a quantity at
or above a threshold, within a circle.* Two things kept it from describing
real parametric terms.

**Its polygons could not be checked.** A v1 trigger bounded by a polygon
carried only `has_territory_polygon: true` into its evidence, not the polygon.
A verifier could not recompute the specification id and reported
`spec_id_unverifiable`. A box is the most common geometry in parametric
hurricane cover, so the most common case was the uncheckable one.

**Its semantics were not a bond's.** Parametric catastrophe terms are written
like this:

- **Hurricane cat-in-a-box.** The storm's *center* passes through one or more
  boxes or polygons. The payout is set by the minimum central pressure (or
  maximum sustained wind) recorded while the center is inside, on a tiered
  schedule. The terms name whose record settles it and which revision — the
  operational advisory as issued, or the post-season best track.
- **Earthquake cat-in-a-circle / cat-in-a-polygon.** The epicenter of the
  preferred origin falls inside a zone, sometimes above a depth limit. The
  payout is tiered by magnitude, on a named magnitude type, per USGS ComCat —
  first report, or reviewed.

v1's §7b admitted this: it defined only `any_single_observation` and named
"maximum over event", "at landfall" and "sustained over window" as common and
undefined. Version 2 defines the rules cat-in-a-box and cat-in-a-polygon terms
actually use, and implements every one it defines.

## 2. What it still does not address

Said first, because the opposite framing is available and false.

- **Indemnity bonds remain out of scope.** They settle on the sponsor's own
  incurred losses, which no third party can observe. Measured on the
  platform's own catalog (CLAUDE.md, migration 181): 161 of 238 bonds.
- **Industry-loss (ILW / index) bonds remain out of scope.** The index is a
  commercial agency's determination (PCS, PERILS). v2 can express a tiered
  schedule; it cannot make the index independently observable. 73 of 238.
- **Pure parametric is what v2 is for, and it is small:** 4 of 238 bonds in
  the catalog. v2 makes those expressible and checkable; it does not enlarge
  the market the standard addresses.
- **Not a settlement determination.** A record says what a public record
  showed against stated terms. Whether anything is owed is for the governing
  documents and the calculation agent they appoint. Every record carries this
  in its own text.
- **Rules not defined:** pressure or wind *at landfall*, values *sustained
  over a window*, "nearest approach" to a point, curved (spline or
  great-circle) tracks between fixes, wind fields
  (a point's modeled wind rather than the storm's), ShakeMap intensity at a
  site, multi-event aggregation across a season. Each is common somewhere;
  none is admitted until it is implemented and its verification is specified.
- **Between fixes, one rule only, and only when the terms say so.** Since
  2026-10-19 terms may carry `measurement.track` (§3.5): the center moves in a
  straight line between consecutive fixes, and the points where that line
  meets a zone's boundary are read. Without the term only the published
  fixes are evaluated, a storm can cross a box corner between fixes and not
  count, and every such hurricane record says so in its caveats. Curved
  tracks are not defined. An interpolated point is never presented as an
  observation: it is a `crossing` in the outcome, naming the two fixes it lies
  between. On Milton (§8) the choice moves the payout from 60% to 100%, which
  is why it is a term and not a default.
- **Alerts follow v1's rules, plus tier steps.** Since 2026-10-18 a v2
  determination is announced through the same path as a v1 one
  (`lib/ils/trigger-alert-fanout.ts`: alert log, position alerts, the account
  and trigger webhooks, email integrations on a breach, the owner's email):
  once on the first record at or above the trigger's alert point, once on a
  breach (any paying tier) after that, and — since 2026-10-19 — again on each
  move from one paying tier to a higher one (`lib/ils/alert-decision.ts`
  `shouldAlertV2`). The account webhook's payload adds
  `determination_standard: "determination/2"` and `payout_pct`, and on a tier
  step `previous_payout_pct` and `tier_step: true`; the per-trigger webhook's
  payload is v1's, with the attachment as its threshold.
- **Historical replay** (since 2026-10-21): `GET /triggers/{id}/backtest`
  replays a v2 trigger year by year through the evaluator itself (§8.1).
- **Forecast outlook** (since 2026-10-21): `GET /triggers/{id}/forecast`
  reads the latest NHC advisory's forecast positions and 5-day cone against
  the zones (`lib/determination/v2/outlook.ts`): whether the forecast track
  enters each zone, when and at what forecast wind (interpolated along a
  straight line between forecast positions, as the track term interpolates
  between fixes), whether the cone reaches it, and for wind terms the tier
  that wind would reach. NHC forecasts no central pressure, so pressure terms
  get no tier from it. An outlook is not a determination: no identifier, never
  evidence, superseded by each advisory.

## 3. The specification

```
spec_id = "cts2-" || lowercase_hex( SHA-256( UTF-8( canonicalize( specification ) ) ) )
```

Same RFC 8785 subset as v1 (`docs/determination-standard-v1.md` §4). The
schema is `docs/trigger-specification-v2.schema.json`.

```jsonc
{
  "standard": "calchis-trigger-spec/2",
  "peril": "hurricane",
  "structure": "cat_in_a_box",
  "zones": [
    { "zone_id": "GULF",
      "geometry": { "type": "box", "south": 25.5, "west": -85.0, "north": 27.0, "east": -83.5 },
      "tiers": [ { "threshold": 965, "payout_pct": 25 },
                 { "threshold": 950, "payout_pct": 60 },
                 { "threshold": 930, "payout_pct": 100 } ] },
    { "zone_id": "TAMPA",
      "geometry": { "type": "box", "south": 27.0, "west": -83.0, "north": 28.0, "east": -82.0 },
      "tiers": [ /* … */ ] }
  ],
  "measurement": {
    "rule": "min_central_pressure_while_center_in_zone",
    "quantity": "central_pressure", "unit": "millibars", "operator": "lte"
  },
  "data_source": {
    "product": "nhc_best_track",
    "authority": "NOAA National Hurricane Center best track (HURDAT2)",
    "revision": "final"
  },
  "risk_period": { "start": "2024-06-01T00:00:00Z", "end": "2024-12-01T00:00:00Z" },
  "payout": { "combination": "greatest_zone", "boundary": "inclusive" }
}
```

### 3.1 Zones

| Geometry | Fields | Notes |
|---|---|---|
| `box` | `south`, `west`, `north`, `east` | `south < north`, `west < east`. A box crossing the antimeridian is refused; write two. |
| `polygon` | `ring`: `[lng, lat]` vertices | Simple (no crossing edges), 3–200 distinct vertices. |
| `circle` | `center_lat`, `center_lng`, `radius_km` | Earthquake terms only. Great-circle (haversine) distance at **R = 6371.0088 km**. |

`payout.boundary` is `inclusive`: a center or epicenter exactly on a zone's
edge is inside it. Terms reading "passes through or on the boundary of" are
the common form, and the opposite rule would let a storm tracking down an edge
never count.

### 3.2 Measurement rules

| `rule` | Peril | Reading | Fires |
|---|---|---|---|
| `min_central_pressure_while_center_in_zone` | hurricane | lowest `central_pressure_mb` among counted fixes inside the zone | at or below a tier (`lte`) |
| `max_sustained_wind_while_center_in_zone` | hurricane | highest 1-minute sustained wind, knots | at or above (`gte`) |
| `magnitude_with_epicenter_in_zone` | earthquake | magnitude of the preferred origin; optional `max_depth_km`, optional `magnitude_types` (ComCat spellings, lower case) | at or above (`gte`) |

Tiers are listed least severe first, and payout strictly increases with
severity. The tier reached in a zone is the most severe one the zone's reading
reaches; a reading exactly at a threshold reaches it. The record's payout is
the **greatest** zone payout (`combination: greatest_zone`); zones do not add.

### 3.3 Source and revision

| `product` | `revision` | What counts |
|---|---|---|
| `nhc_public_advisory` | `first_report` | The center fix, pressure and wind NHC issued with each advisory, timed at the advisory's analysis time. |
| `nhc_best_track` | `final` | HURDAT2, NHC's post-season best track (IBTrACS carries it as the `USA_*` fields). |
| `usgs_comcat` | `first_report` | The first preferred origin recorded for the event. |
| `usgs_comcat` | `final` | The preferred origin once ComCat marks it `reviewed` — the latest such revision. |

Operational advisories and the best track disagree, often by several
millibars and tenths of a degree, and terms choose one. That choice is the
term most argued about, so it is named. An observation of a different product
or revision is kept in the record and **not counted**.

For ComCat, `first_report` means the first revision *the issuer recorded*,
which can be later than USGS's own first publication by up to one worker
interval; records say so in their caveats.

### 3.4 Normalization

Two parties writing the same terms must compute the same id, so terms are
normalized before hashing:

- zones sorted by `zone_id` (UTF-16 code-unit order, as RFC 8785 sorts keys);
- tiers sorted least severe first;
- a polygon ring has its closing vertex and consecutive duplicates removed,
  is wound counter-clockwise (RFC 7946's exterior orientation), rotated to
  start at its smallest `[lng, lat]` vertex, and closed again;
- `standard`, `structure`, `measurement.quantity/unit/operator`,
  `data_source.authority` and `payout` are derived from the rule and product;
- magnitude types are lower-cased, de-duplicated and sorted.

**No number is rounded.** A rounded corner or threshold is a different
contract, and normalization will not make that change on anyone's behalf.

Changing any term — moving one corner by a hundredth of a degree, one tier's
payout, the revision, the risk period, adding the track term or changing its
gap — changes the id.

### 3.5 Between fixes (`measurement.track`, hurricane only)

```json
"track": { "method": "linear_between_fixes", "max_gap_hours": 6 }
```

Absent, the published fixes alone are evaluated (the rule before
2026-10-19; specifications without it hash exactly as they did). Present:

1. **The track.** The fixes that pass every test except the zone test (§4)
   — of the stated product and revision, standing, timed inside the risk
   period, carrying the reading — are ordered per storm (`source_event_id`)
   by `record_time`, then `source_record_id`. Each consecutive pair whose
   second time is later than its first by no more than `max_gap_hours`
   (1–24), and whose longitudes differ by less than 180°, is a segment. A
   pair straddling the risk period's start or end is not joined, because its
   outer fix is not counted.
2. **The line.** Along a segment from fix `a` to fix `b`, at fraction `f`,
   every quantity is linear: `lat = a.lat + f × (b.lat − a.lat)`, likewise
   `lng`, the time (rounded to the millisecond) and the reading. Straight in
   the latitude/longitude plane, not a great circle.
3. **Crossings.** For a box, `f = (edge − a) / (b − a)` on the axis of each
   of the four edges, with the point's coordinate on that axis set exactly to
   the edge, kept when `0 < f < 1` and the other coordinate is inside the box.
   For a polygon, every proper edge intersection and every vertex lying on the
   segment. Each point inside the zone (inclusive) is a crossing:
   `crossing_id = "crossing:<zone_id>:<from>..<to>:<k>"`, `k` counting that
   zone's crossings on the segment from 0. The readings are not rounded.
4. **Readings.** A zone's reading is the most severe of its counted fixes and
   its crossings. Because the reading is linear along a segment, its most
   severe value on the part inside a zone is at an end of that part — a fix
   or a crossing — so this is exact, not a sampling of the line. On a tie the
   earlier time, then the lower id, wins, as for fixes.

The fixes at the ends of a segment are counted or excluded exactly as without
the term; a fix outside every zone stays `outside_every_zone` even when the
line from it crosses one.

## 4. Observations

One observation is one record from the named source:

| Field | Meaning |
|---|---|
| `source_record_id` | Which published version this is (an advisory, a ComCat update). |
| `record_key` | What it is *of* — one center fix, one earthquake. Revisions share it. |
| `product`, `revisions` | What the record is, and which revisions it counts as. |
| `record_time` | When the source says the fix or origin is valid. Null when the source does not say — then it is not counted (`no_record_time`), never assigned the issuer's clock. |
| `captured_at` | When the issuer read it. |
| `lat`, `lng`, and the readings | `central_pressure_mb`, `max_sustained_wind_kt`, `magnitude`, `magnitude_type`, `depth_km`. |

Several revisions of one record are grouped by `record_key`: `first_report`
keeps the earliest captured, `final` keeps the latest; the others are
`superseded`. An observation is counted when it is of the stated product and
revision, standing, timed inside the risk period (`start <= time < end`),
carries the rule's reading, passes the magnitude-type and depth terms, and is
inside at least one zone. Every observation not counted is recorded with the
reason (`wrong_product`, `wrong_revision`, `superseded`, `no_record_time`,
`outside_risk_period`, `no_reading`, `magnitude_type_not_admitted`,
`deeper_than_limit`, `outside_every_zone`).

## 5. The determination record

```
determination_id = "cdt2-" || lowercase_hex( SHA-256( UTF-8( canonicalize( evidence ) ) ) )
```

Schema: `docs/determination-record-v2.schema.json`.

| Key | Contents |
|---|---|
| `record_standard` | `calchis-determination/2`, inside the hash, so a record separated from its attestation still names its rules. |
| `captured_at` | When the issuer assembled the evidence. |
| `specification` | `spec_id`, the full normalized `terms`, and a plain-language `statement`. |
| `event` | The issuer's event id (null for a backtest), title, type, `source_event_id` (ATCF / ComCat id), `source_url`. |
| `trigger` | The issuer's trigger id and name, or null. Operational, not a term. |
| `observations` | Every observation considered. Records are cumulative: each record for a storm carries every fix seen so far. |
| `outcome` | `status`, `payout_pct`, `deciding_zone_id`, the deciding `reading`, its `reading_record_id` and `reading_time`, `tier_index` and `tier_threshold`; per zone the same; per observation the zones containing it, whether it was counted and why not. With the track term, `crossings`: each with its id, zone, the two fixes it lies between, `fraction`, time, position and reading. |
| `statement`, `disclaimer`, `caveats` | Restatement with the numbers; the not-a-settlement-determination text; limitations of this reading. |

`outcome.status` is one of `tier_reached`, `in_zone_no_tier`,
`not_in_any_zone` (observations of the stated source exist, none inside a
zone) and `no_qualifying_observation` (nothing of the stated source and
revision was recorded — for example, best-track terms against a live storm).

## 6. Verification

`lib/determination/verify.ts` dispatches on the record: a v2 record is
recognized from `evidence.record_standard` (or a `cdt2-` id, or a v2
attestation) and checked by `lib/determination/v2/verify.ts`; a v1 record is
checked by the v1 rules, unchanged. The hosted endpoint
`POST /api/v1/determinations/verify` takes both, unauthenticated, reading
nothing from any database.

A v2 verifier **re-runs the evaluation** on the record's own observations and
terms, and reports every finding:

| Code | Severity | Condition |
|---|---|---|
| `id_mismatch`, `content_hash_mismatch` | fatal | The id or attested hash does not name this evidence. |
| `unknown_hash_algorithm` | fatal | Not `sha-256`. |
| `unknown_standard` | advisory | Attestation names another standard; checked as v2. |
| `terms_invalid` | inconsistent | The terms are not valid v2 terms. |
| `terms_not_normalized` | inconsistent | The terms are valid but not in normalized form. |
| `malformed_spec_id`, `spec_id_mismatch` | inconsistent | The spec id is not `cts2-`, or does not hash from the terms. |
| `zone_membership_mismatch` | inconsistent | An observation's recorded zones are not the zones its position is in. |
| `counting_mismatch` | inconsistent | An observation is recorded counted/excluded (or for a reason) contrary to the terms. |
| `reading_mismatch`, `tier_mismatch` | inconsistent | A zone's reading, its source record, its tier or its payout differ from re-evaluation. |
| `payout_mismatch`, `status_mismatch` | inconsistent | The record's payout, deciding zone or status differ. |
| `crossings_mismatch` | inconsistent | With the track term, the recorded crossings are not the ones interpolating the record's own fixes gives. |
| `assessment_missing`, `assessment_count_mismatch`, `zone_missing`, `outcome_mismatch`, `no_outcome` | inconsistent | The outcome is incomplete or otherwise differs. |
| `no_observations`, `no_disclaimer` | inconsistent | Nothing to trace the outcome to; no disclaimer. |
| `observation_after_capture`, `observation_after_evidence` | inconsistent | A record time after its capture; a capture after the evidence. |
| `spec_statement_differs`, `statement_differs`, `no_source_url` | advisory | Prose not the derived prose; no citation URL. |

`spec_id_unverifiable` does not exist in v2. Geometry is a term, so every
specification id is recomputable from the record alone.

## 7. Where the platform gets observations

- **Hurricanes, live:** the NHC worker's event row — center fix
  (`events.lat/lng`), `raw_data.min_pressure_mb`, `wind_speed_kt`, advisory
  number and analysis time (`YYYYMMDDHH`). Product `nhc_public_advisory`,
  revision `first_report`. Each trigger-eval run adds the current fix to the
  previous record's observations.
- **Hurricanes, final:** HURDAT2 / IBTrACS best-track points
  (`historical_hurricane_tracks`, or a fixture). Product `nhc_best_track`,
  revision `final`. Not available for a live storm.
- **Earthquakes:** the USGS worker's event row — epicenter, magnitude, and
  (from 2026-10-17) `raw_data.mag_type`, `depth_km`, `review_status` and
  `usgs_updated`, kept current on every re-fetch.

## 8. Backtest

`tests/lib/determination/v2/backtest-milton.test.ts` runs a two-box pressure
schedule over Hurricane Milton (2024): IBTrACS SID `2024279N21265`, ATCF
`AL142024`, the HURDAT2 records from 06 UTC 9 October to 12 UTC 10 October,
copied unchanged into `tests/fixtures/ibtracs-milton-2024-landfall.json`
(source, retrieval date and the interpolated rows deliberately left out are
recorded in the file). GULF (25.5–27.0°N, 85.0–83.5°W) holds the 18 UTC 9 Oct
fix at 945 mb and reaches the 60% tier; TAMPA (27.0–28.0°N, 83.0–82.0°W) holds
the 00 UTC fix (956 mb) and the 00:30 UTC landfall row (958 mb) and reaches
25%; the 907–909 mb fixes of 9 October lie south and west of both. The record pays 60%, decided by
GULF, and the same observations do not settle terms written on the
operational advisories (`no_qualifying_observation`). The resulting `cdt2-`
record verifies offline.

The same storm on the same boxes **with** `track: {method:
"linear_between_fixes", max_gap_hours: 6}` pays **100%**. The 12 UTC fix on 9
October (25.0°N 84.8°W, 919 mb) is south of GULF; the 18 UTC fix (26.3°N
84.0°W, 945 mb) is inside it. The line between them crosses GULF's south
edge, 25.5°N, at `f = 0.5 / 1.3 = 5/13`, where the interpolated pressure is
`919 + (5/13) × 26 = 929` mb — one millibar inside the 930 mb tier. TAMPA's
reading moves from 956 mb to 953.46 mb, entered across 83°W between 18 and
00 UTC, and stays at 25%. Whether a term sheet interpolates is therefore worth
40 points of principal on this storm; the record names the two fixes and the
fraction so a counterparty can repeat the arithmetic by hand.

### 8.1 The replay over the record

`GET /api/v1/triggers/{id}/backtest` on a v2 trigger
(`lib/determination/v2/backtest.ts`) settles each year of the record with
`evaluateV2` — the function that settles the live trigger and that the
verifier re-runs — with the risk period moved to that year by whole years and
nothing else changed. The unit is the risk period, not the storm: two
qualifying storms in one season are one payout, the greater, as under the
terms. A risk period longer than 366 days is refused (409), because shifted
copies would overlap.

- **Hurricanes** read `historical_hurricane_tracks` (IBTrACS North Atlantic,
  whose USA fields are HURDAT2). IBTrACS adds a point every three hours
  between NHC's six-hourly fixes, and the ingest kept no flag; those points
  are not NHC records and are set aside. They fall at 03, 09, 15 and 21 UTC
  exactly, and NHC's own records at those times (214 in HURDAT2 1851–2025 —
  landfalls and peaks such as Idalia's 115 kt at 09 UTC 30 August 2023) are
  kept, from a list generated from the HURDAT2 file
  (`scripts/fetch-hurdat2-three-hourly-records.ts`,
  `lib/modeling/generated/hurdat2-three-hourly-records.ts`). Every one of them
  from 1900 on matches a stored row exactly; 18 from 1851–1898 do not (IBTrACS
  carries an older reanalysis of those years), outside both default windows.
  Terms on the operational advisory are replayed on the best track in its
  place (`substituted: true`) — no archive of advisories is held. With the
  track term, the read reaches `100 km/h × max_gap_hours` beyond the zones, so
  a storm moving faster than ~54 kt between two fixes outside that reach would
  be missed; the result says so. Zones entirely west of the 100th meridian west are refused:
  Pacific storms are not in the record.
- **Earthquakes** read `historical_earthquakes` (ComCat, US and territories),
  each event's preferred origin as ComCat holds it now. Terms on the first
  report are replayed on it (`substituted: true`); the stored catalog has no
  magnitude type, so a type restriction is not applied and the result says so.
  The depth limit is applied.
- Default windows: central pressure from 1990, wind and magnitude from 1900,
  to the last complete year.

On the §8 boxes, 1990–2025, against production (2026-10-21): on the fixes
alone 4 of 36 years pay — Andrew 1992 (943 mb, GULF, 60%), Dennis 2005 (942 mb,
GULF, 60%), Idalia 2023 (965 mb, GULF, 25%), Milton 2024 (945 mb, GULF, 60%) —
a mean of 5.7% of principal a year. With the track term (6 h), 6 of 36 pay and
the mean is 9.2%: Milton reaches 100% (929 mb), and Charley 2004 (944 mb,
TAMPA, 60%) and Irma 2017 (953 mb, TAMPA, 25%) enter TAMPA between fixes, with
no fix inside it. 73 of the 152 points read on the fixes-only run were IBTrACS
interpolations.

## 9. Storage and rollout

Migration 204 adds `ils_triggers.spec_version` (1 or 2) and `ils_triggers.spec`.
A v2 row keeps `peril`, `threshold_type` and `threshold_value` (the
attachment, the least severe tier, in the canonical unit) so list views work;
its terms are `spec`, and the v1 evaluator never reads it. Until 204 is
applied, `POST`/`PATCH /api/v1/triggers` with `spec_version: 2` answer 503
`{ pending_migration: 204 }`. v2 determinations are written to
`ils_trigger_events` like v1 ones, with `determination_id = cdt2-…`, and are
sealed into the same daily Merkle log (`docs/determination-standard-v1.md` §8b), whose roots are independently timestamped through OpenTimestamps (`docs/digest-anchoring.md`).

## 10. Governance

As v1: published for criticism; one implementation; no independent
reproduction yet. The two things that would matter most — a second
implementation agreeing on identifiers, and neutral stewardship — have not
happened.
