# The event-to-outcome record

**Since:** migration 217 (written 2026-10-29). **Table:** `event_outcomes`. **Writer:** `/api/workers/event-outcomes`, daily. **Reader:** `GET /api/v1/events/{id}/outcome` (public). **Logic:** `lib/outcomes/event-outcomes.ts`.

## What it is

One row per in-scope event, joining what the platform recorded as the event happened to what the federal record says came of it:

- the county the event sits in (`lib/geo/county-at-point-offline.ts`);
- the FEMA declarations tied to it (`historical_declaration_areas`, migration 154) and how the tie was made (`storm`, `county` or `state`), the first declaration date and the days it took;
- the IHP and PA dollars and households FEMA reports under those declarations (OpenFEMA `FemaWebDisasterSummaries`, read at compute time);
- what agencies measured afterward: the count, product types and maximum intensity of the event's observed footprints (migration 203);
- the housing the platform estimated under the event while it was live (`events.estimated_housing_units_exposed`).

It is the fourth item of the data-asset program (`docs/as-issued-archive.md`, `docs/hindcast/`, `docs/warning-ledger.md` are the first three) and the input a calibration of the consequence rings against outcomes will read.

## How a declaration is tied to an event

A declaration area row matches when its incident type is in the event type's family (`INCIDENT_FAMILIES`: a hurricane event matches Hurricane, Tropical Storm, Coastal Storm, Tropical Depression; a flood matches Flood, Severe Storm, Coastal Storm, Dam/Levee Break, Mud/Landslide; a wildfire only Fire; and so on) and its incident period began within 7 days before or 21 days after the event's start (seven before, because FEMA's incident period for a hurricane starts with the emergency declaration, days ahead of landfall). Three ties, in order:

- **`storm`** — a named tropical cyclone (an NHC `hurricane` event) is tied to every declaration whose title carries the storm's name (FEMA titles them "HURRICANE ISAIAS"), in every state. A hurricane event is the storm, not a place — its coordinates are the center and it has no state or county — so this is the only rule that can reach it, and the storm's declarations are the storm's, not the landfall county's. A numbered depression has no name and matches nothing.
- **`county`** — a row naming the event's county, in the event's state.
- **`state`** — when none names the county, the state's rows for that disaster.

A disaster number is matched once. The window is short on the late side so that a busy season's next storm does not pool onto this one. States are compared after normalization: events carry ISO 3166-2 (`US-NM`), declaration areas the postal code (`NM`).

**Before `outcomes-3` (2026-10-31) nothing had ever matched.** The two state forms were compared as stored and were never equal, and hurricanes had no state to compare; the first morning's rows for the New Mexico floods of 2026-10-22 (EM-3660, with the warned counties designated) and for Hurricane Isaias (DR-4955 and three emergency declarations) carried nothing. `outcomes-3` fixes both (migration 225 admits `storm`); rows under `outcomes-1` and `outcomes-2` stay as written.

## Scope

Not every event. The live feeds write tens of thousands of warnings a quarter that no agency will declare or survey. An event is in scope when its type is one FEMA declares and either it is a hurricane, tornado or earthquake, or the platform estimated at least 1,000 housing units under it (`MIN_HOUSING_EXPOSED`). An earthquake counts only inside US coverage — a state on the row, or a US county under the epicenter — because the USGS feed is global and FEMA declares nothing abroad. The rule is in the method version.

## The pass

A pass computes at most 1,500 events (`MAX_EVENTS_PER_PASS`) of the 120-day window. Under `outcomes-1` it took the newest 1,500 and, with the world's M2.5+ earthquakes in scope, the first run's oldest row was 18 days back, not 120 — the rest of the window was never reached. Since `outcomes-2` (2026-10-31) every in-scope event in the window is listed and the pass is a rotation: events with no row for this method version first, newest first, then the rest by the age of their row, oldest first (`selectForPass`). The window is covered within a few runs and every row is refreshed in turn. The worker reports `coverage` — the window's events, how many had a row before the pass, the oldest row's age, and the share covered before and after — and logs a warning while the share is below 100%, so a window the passes do not keep up with is never silent. Rows written under `outcomes-1` stay in the table under that version.

## What a row does not say

- **Not attribution of dollars.** A declaration covers a whole incident and often several counties and events; the dollars are the declaration's, repeated on every event tied to it. Sum them per declaration, not per event.
- **Not an absence of impact.** No declaration means the record holds none within the window — a declaration can come later (the row is rewritten daily for 120 days), or an event's harm can fall under another incident's declaration.
- **Not NFIP.** Flood claims are kept by county and year of loss, not by event; a per-event flood-claims figure is a later, coarser join.
- **Rewritten, not append-only.** Declarations arrive for weeks and dollars for years; each event's row is replaced per method version — in rotation, so a row's `computed_at` says when it was last refreshed, and a declaration that arrived after it is not on it yet. The archive underneath (migration 212) is what does not change.

## Verifying

```sql
SELECT event_type, count(*) AS events, count(*) FILTER (WHERE declarations <> '{}') AS declared,
       round(avg(days_to_declaration)) AS avg_days_to_declaration
FROM event_outcomes WHERE method_version = 'outcomes-2' GROUP BY 1 ORDER BY 2 DESC;

-- Coverage of the window: how far back the rows reach, and how stale the oldest is.
SELECT min(started_at) AS oldest_event, max(started_at) AS newest_event, min(computed_at) AS oldest_row, count(*) AS rows
FROM event_outcomes WHERE method_version = 'outcomes-2';
```
