# Footprint exposure at issuance

**Since:** migration 220 (written 2026-10-30). **Table:** `footprint_exposures`. **Writer:** `/api/workers/footprint-exposure`, every 10 minutes. **Reader:** `GET /api/v1/verification/footprint-exposure?month=YYYY-MM[&kind=spc_day1_outlook|fire_perimeter]` (public). **Logic:** `lib/exposure/footprint-at-issuance.ts`; the arithmetic is `compute_warning_exposure` (218), unchanged: `compute_housing_exposure_v2` (198) plus the polygon's area on the spheroid.

## What it is

The warning record (`docs/warning-exposure.md`) measures the 2020 housing under every NWS warning polygon minutes after it is issued. Two other footprints the live workers read carry the same question, and nobody records the answer as issued:

- **SPC Day 1 convective outlook.** Five issuances a day (0600, 1300, 1630, 2000, 0100Z), one polygon per risk category from General Thunderstorms to High Risk. "Homes under an Enhanced Risk at 1630Z" is quoted constantly and never kept with the time it was computed. One row per issuance per category.
- **NIFC daily fire perimeters.** A perimeter is redrawn as a fire grows and the current service overwrites the last; the history NIFC publishes later does not say which version was current when. One row per perimeter version the archive received, keyed by the fire's IRWIN id, so the rows are the fire's growth-exposure series as it was received.

Each row names the archived body it was computed over (`product_row_id`, `content_hash`), which `GET /api/v1/archive/products/{hash}` returns byte for byte, and carries the lags from the product's own issue time to the archive's receipt and from receipt to the figure.

## Why it is hard to replicate

The same three sentences as the warning record, and they are true here too: the figure is recomputable by anyone with the polygons and the tracts; the record of when the platform had it is not; the lags are the asset, and they only accrue forward. Two things are specific to these footprints:

- **The outlook's figure is the figure at the time.** SPC archives its outlook polygons, so the homes under any past outlook can be recomputed later. What a buyer of a convective-risk product wants to know is what the platform read at 1630Z on the day, before the storms, not what it could have read afterward. That is the row.
- **The perimeter series does not exist elsewhere.** NIFC's current-perimeter service is overwritten; the WFIGS history layer carries each fire's perimeters but not the time each one was the current one, and the platform's rows carry both the polygon date NIFC states and the time the archive received it.

## What a row says — and does not

- **`housing_units`** is 2020 Census housing under the polygon: tracts weighted by developed land (NLCD) and areal overlap, the same `tract_areal_overlap_v1` as every exposure figure on the platform. Homes, not people; not a damage estimate; not a forecast.
- **`area_km2`** is the polygon's area on the WGS84 spheroid.
- **`kind`** is `spc_day1_outlook` or `fire_perimeter`. **`footprint_key`** is `<ISSUE>:<LABEL>` (`202610300028:MRGL`) for an outlook and the IRWIN id, lower case, for a fire. **`category`** is SPC's label (`TSTM`, `MRGL`, `SLGT`, `ENH`, `MDT`, `HIGH`) or NIFC's incident type category (`WF` wildfire, `RX` prescribed burn). **`label`** is the category's name or the incident's.
- **SPC categories do not nest in these rows.** The platform reads SPC's non-layered categorical file (`day1otlk_cat.nolyr.geojson`): each category's polygon excludes the higher categories, so a Slight Risk row is the Slight band alone and "Slight or higher" is a sum across bands. The summary's `peak` is that sum for the issuance where it was largest.
- **`issued`, `valid_from`, `valid_to`** are SPC's ISSUE, VALID and EXPIRE read as UTC; for a fire, `issued` is the perimeter's current date (else its modified time), `valid_from` the polygon date NIFC states, `valid_to` null. **`state`** and **`county_fips`** are a fire's point of origin. **`size_acres`** and **`percent_contained`** are as the feed carried them.
- **`receipt_lag_seconds`** runs from `issued` to the archive's `received_at`. The archive polls SPC every 5 minutes and NIFC twice a day (0007 and 1207Z), so a fire's receipt lag is mostly the poll interval, and an outlook's is minutes. **`compute_lag_seconds`** runs from receipt to `computed_at` (the worker runs every 10 minutes). Both are measured, never typed.
- **A row with `housing_units` null** could not be measured within the database's statement budget after a retry (**`attempts`** says how many: 2 after a timeout, 1 after any other failure). It is kept as it was and not recomputed. A continent-scale General Thunderstorms area (1.5 million km², 8,000 tracts) can miss the budget cold and make it warm; the retry is for that.
- **`METHOD_VERSION`** (`footprint-exposure-1`) marks the method. A change is a new version; the worker writes new rows under it and the old rows stay.
- **The record begins 2026-10-28**, when the as-issued archive began. Nothing earlier is backfilled, and nothing can be: a lag cannot be measured after the fact.

## How the worker walks the archive

`as_issued_products` is append-only and its `id` is insertion order. The worker reads the highest `product_row_id` it has written under the current method version (0 when none), then reads bodies with `source IN ('spc_day1_outlook', 'nifc_perimeters')`, `id > watermark` and a non-null geometry, oldest first, 25 at a time, computing each and writing the rows. It loops while a pass fills, within an 85-second budget; a pass that runs out of budget writes what it computed and the next run resumes after the last row written, not the last one read. Nothing is skipped and nothing is read twice. A read failure writes nothing; an upsert failure stops the pass with the rows written so far counted.

## Verifying a row

```sql
SELECT kind, category, label, issued, received_at, computed_at,
       receipt_lag_seconds, compute_lag_seconds, housing_units, area_km2, attempts, content_hash
FROM footprint_exposures
WHERE footprint_key = '202610300028:MRGL'
ORDER BY product_row_id;
```

Then `GET /api/v1/archive/products/{content_hash}` returns the polygon that was measured; hash the canonical body again and it matches; `SELECT * FROM compute_warning_exposure('<that geometry>'::jsonb)` returns the same figure.

## What it is not

- Not a verdict on the outlook or the fire. Nothing here says whether storms formed or homes burned; the event-to-outcome record (`docs/event-outcomes.md`) is where that is joined.
- Not people, value or damage. Housing units only.
- Not the whole SPC product. Only the Day 1 categorical outlook is archived; probabilistic (tornado, wind, hail) and Day 2–8 outlooks are not read by the platform and are not rows.
