Skip to content

Phase 9 — the Full-Catalog walkthrough (owner-run, phase exit)

What this is. Phase 9's verifiable goal, from phase-9-data-foundation.md § Goal. The phase does not close on a merge count — it closes when these six steps pass in one sitting, manually, through the real UI and API, against a stack pointed at the Hetzner object store.

Companion: phase-9-wave-0-walkthrough.md — the six-moment Showcase walkthrough. Run wave-0 first; it is the cheaper gate and it shares most of its preconditions with this one.

Sitting 2026-08-25 — all six steps passed

Phase 9 is closed on this sitting. The owner ran the walkthrough end to end against production — backbone v0.17.0 (a5a8404), UI v0.7.0, Alembic 0086 — and reports all six steps passed. Everything below this section is the preparation record that led here; it is kept as the dated account of how the sitting was set up, not as pending work.

This retires the "closed by supersession" framing recorded in plan.md's status log on 2026-08-24. The phase exits on its stated criterion met, which is what the If a step fails rule at the bottom of this file was written to protect: Phase 8 exited once on a waived walkthrough criterion and carried four defects forward, and Phase 9 did not repeat it.

Step 4b — the coastal half, walked for the first time. The reinstated two-hazard form of step 4 (see Step 4's two-hazard form was reinstated on 2026-08-19) completed its grid in production. Six scenario cells ran at 12:12 UTC, seconds apart; the baseline column is served by the year-invariant baseline dataset, whose result dates from the 2026-08-19 seed run. Read against exposure LitPop Greece in northlane:

Scenario Horizon AAI Job id
ssp2-4.5 2030 €29.06 M 4d4f6570-91b5-45e1-84f1-791ca2293a9e
ssp2-4.5 2050 €34.04 M 26233eb8-5fe9-4067-a469-67f8d107c753
ssp2-4.5 2080 €44.99 M 4de7814c-c61b-433e-9bea-cc2178950b7f
ssp5-8.5 2030 €29.88 M 70cd5a66-94b0-4d44-b41c-8639109fb9d5
ssp5-8.5 2050 €35.58 M f6ed164b-5845-4fdc-bd0f-82656a3a18f7
ssp5-8.5 2080 €50.37 M 29c4aadc-fb58-4757-a781-9e470e861a8e
baseline 1980 €22.9 M seeded 2026-08-19

The values reproduce the 2026-08-19 seed run cell for cell, which is the expected result for the same (dataset, exposure, curve) triples and is itself a reproducibility check. Eleven river-flood results were written the same day, covering step 4a's grid and step 3's compute.

Per-org curve seeding verified. northlane holds 15 org-scoped impact-function rows — river flood ×3, coastal flood ×3, earthquake ×4, wildfire ×3, storm_europe ×1, tropical cyclone ×1 — so the coastal curves shipped by #833/ADR-076 are present in the org that ran the grid.

Together those two facts discharge #970 in full, which was the only Phase 9 item carried into Phase 10.

One criterion closed as dispositioned, not executed — the live polling→review→approve→ingest cycle. It is recorded honestly in phase-9-data-foundation.md § Exit criteria: the prod queue still holds the single jrc_flood row stuck at approved from 2026-08-12, and no end-to-end auto-ingest has ever committed a dataset. What the sitting does prove about pipeline unity is step 6's audit.

The three post-pass actions from After it passes are done: the phase-exit box in phase-9-build-plan.md is ticked, and plan.md's Phase status row and status log record the close. The third — the standing rule that nothing publishes publicly before the licensing verdict — is unchanged; licensing is parked with the End-State Vision.


Read this before you book the sitting — both blockers are now CLEARED

Status 2026-08-10: book the sitting. The heading here used to read "one hard blocker remains", and the paragraph under it said "This walkthrough is not runnable end-to-end today, and the remaining blocker is not a code defect on trunk. It is work that has not been done yet." Both are kept above as the record and both are now false — the ops work they referred to has been done. Blocker 1 (UI) cleared 2026-08-07; blocker 2 (catalog contents) cleared 2026-08-10, when the last three packs landed in production and the ≥6-hazard-type condition was measured passing on both the DB and the API. Details in each blocker's own section below.

~~Blocker 1 — the UI half of steps 2 and 5 does not exist~~ — CLEARED 2026-08-07

Step Needs State
2 — pick between ≥2 datasets for the same peril ui#100 dataset picker per hazard CLOSED, deployed in ui v0.5.0
5 — every lookup answer names its dataset ui#101 dataset provenance chip CLOSED, deployed in ui v0.5.0

Both shipped in climate-lama-ui v0.5.0 and are live on prod as of 2026-08-07 12:23 UTC (UI_TAG=v0.5.0; deploy run 31177920318). Verified beyond the deploy's own health gate — which only asserts HTTP 200 and would pass on a stale container — by grepping the served bundle for the new strings (Auto (recommended), Indicator (not lossable), Screening-grade EAI) and the dataset_id param. See phase-9-build-plan.md § UI shipped separately as v0.5.0 for the full evidence table.

Steps 2 and 5 can therefore be walked through the real UI, as the goal requires — the old "API-only PASS / UI PENDING" fallback no longer applies. ~~Note this clears the surface, not the data: with the catalog still single-source (blocker 2), the picker has nothing to pick between, so step 2 only becomes meaningful once blocker 2 is done.~~ Superseded 2026-08-10 — the catalog is no longer single-source, so the picker now has something to pick between; see blocker 2.

~~Blocker 2 — the catalog is empty of real packs~~ — CLEARED 2026-08-10

All ten volume-ledger rows now read ingested, and prod carries 6 distinct hazard types. This supersedes the text that stood here, kept verbatim: "The ops half — licence acceptances, downloads, prod ingest — is owner work and has partly run (resynced 2026-08-10): licences accepted 2026-08-07, and 7 of the 10 volume-ledger rows now read ingested. Step 1 needs ≥6 hazard types actually in the catalog, and the 3 rows still outstanding (#614/#615/#616, the NetCDF packs) are exactly the ones that would supply the missing types — so this walkthrough remains gated on the ops track finishing."

Those three rows landed later the same day. Two of them are partial by the ledger's own vocabulary, and neither partial is visible in this walkthrough: #614's C3S projections half awaits rotated-pole reprojection (its EWDS half is ingested and is what supplies wildfire), and #615's WISC synthetic half was not retrieved at all because that CDS product's process schema publishes no spatial input to scope it with (its C3S half supplies storm_europe). The ≥6 distinct haz_type condition is measured passing — see the OPS-5e table in the next section.

The goal's second half — ≥2 distinct source values for at least two hazard types — is also measured passing (prod, 2026-08-10). It was originally recorded here as a derivation from the ledger, because the OPS-5e run counted haz_type only; it has since been measured directly:

SELECT haz_type, count(DISTINCT source) FROM hazard_datasets
 WHERE status='active' GROUP BY 1;
haz_type distinct source the sources
river_flood 2 JRC River Flood Hazard Maps v3.1.1 · WRI Aqueduct Floods v2
heatwave 2 ECDE / ERA5 heat wave days · EURO-CORDEX heat wave days (health)
coastal_flood 1 WRI Aqueduct Floods v2 — coastal
earthquake 1 ESHM20
storm_europe 1 C3S windstorm tracks + footprints
wildfire 1 CEMS GEFF/ERA5 FWI historical (cems-fire-historical-v1)

Exactly two hazard types carry two sources each, which satisfies "at least two." Note how little headroom that is: if either river_flood's JRC row or one of heatwave's two retrievals were to drop out of active, this half of the goal would fail while the ≥6-type half kept passing. Worth re-running the query on the day you walk this rather than trusting this table.

There is no "raw-zone upload" step, and there never was. Per ADR-070 the raw zone is transient by design: packs stream to local disk, clip, ingest, delete. Phase 9's exit criterion 3 is amended accordingly, so step 6 audits the per-dataset source manifest (hazards/{dataset_id}/source_manifest.json, ADR-066; staging/{ingest_job_id}/manifest.json for pre-ADR-066 datasets) — not a raw/ copy. Do not fail a dataset for having no raw-zone object; none of the ten packs has one.

Back-filling the pre-ADR-066 datasets. Measured 2026-08-10: only 5 of 21 active datasets have the dataset-scoped key; the other 16 still have their provenance at the legacy staging/{ingest_job_id}/manifest.json. scripts/backfill_source_manifests.py moves them onto the dataset-scoped key (verbatim — the captured checksums survive), and reconstructs a manifest from the pack definition plus the catalog row for any dataset whose staging record is gone. A reconstructed manifest is stamped capture_mode: "reconstructed" and leaves checksum_sha256/volume_bytes null — when auditing step 6, read that flag: it distinguishes a record captured at ingest from one assembled after the fact.

Check where you stand before booking time:

curl -fsS -H "Cookie: <session>" \
  "https://api.climate-lama.online/v1/hazards?limit=200" \
| jq -r '.data[] | "\(.haz_type)\t\(.source)\t\(.name)"' | sort | uniq -c

You need ≥6 distinct haz_type values, and ≥2 distinct source values for at least two of them. ~~As of 2026-08-07 prod had 8 datasets, all river_flood, all one org — so this returns one row. That is the gap.~~ SUPERSEDED — the ≥6-type half of that gap is CLOSED, measured on prod 2026-08-10 (OPS-5e), and both sides agree exactly:

haz_type count
coastal_flood 7
earthquake 1
heatwave 3
river_flood 8
storm_europe 1
wildfire 1

6 distinct types, 21 active datasets. That is the DB read (via a psql superuser connection — note the trap: the app connection returns zero rows here under RLS, so a bare app-side query looks like an empty catalog when it is nothing of the kind). The API read agrees exactly: GET /v1/hazards with status=active returns 21 rows across the same 6 types with an identical breakdown, unfiltered returns 31 rows across the same 6 types, and a per-type ?haz_type= round-trip returns 200 with matching counts for all six. #735 was closed on this evidence. Re-run the curl above to confirm against the state of the day you walk this — but expect it to pass, not to show the old one-row gap.

~~Also note #706's consequence: the CDS/EWDS packs still need their licence acceptances.~~ Both halves of that are superseded (2026-08-10). Licence acceptances were completed by the owner 2026-08-07 — do not re-ask; a real authenticated EWDS retrieval succeeded on 2026-08-09 (jobID fd5475dd-c034-4ff5-a696-83e8314a3952), which an unaccepted licence would have refused. And #706 is only the first of six NetCDF blockers, all now closed (#706/#761-code/#782/#791/#793/#797). ~~What actually holds #614/#615/#616 today is the assert_netcdf_adapter_wired evidence gate — no NetCDF ingest has ever run end to end — tracked on the still-open #761.~~ SUPERSEDED later on 2026-08-10, and this correction matters for anyone walking this doc: NOTHING holds #614/#615/#616 out of prod any more. The first end-to-end NetCDF ingest ran that day, PR #825 (42f2a35) deleted the evidence-gate layer of the guard along with its --first-end-to-end-run escape, and #761 is CLOSED. All three packs then landed in production: heatwave (3 datasets), windstorm (1), wildfire (1). Do not tell the owner a NetCDF pack is blocked — none is. The only residue is inside #614's C3S half (rotated-pole reprojection, not yet built) and #615's WISC half (not retrieved, because its CDS process schema publishes no spatial input), and neither is visible in the walkthrough. Read NETCDF_INGEST_BLOCKERS in scripts/packs/_wildfire_fwi_common.py on trunk for live status; never quote a blocker number from this doc.

Execution runbook and the per-dataset evidence: phase-9-ops-plan.md.

Preconditions

Re-assert each one; do not inherit them from this table. The lesson behind this is #638 — a config change was once applied to an image that could not support it, and every health check still looked green.

Thing How to check Expected
Deployed backbone contains the code you mean to test curl -fsS https://api.climate-lama.online/v1/info \| jq -r .data.version,.data.git_sha 0.16.0 / a70cc3e… or later (measured 2026-08-19)
That SHA really contains a given fix git merge-base --is-ancestor <fix-sha> <deployed-sha> exit 0
The deploy actually swapped the containers scripts/verify-deploy.sh v0.16.0 identity check passes. A liveness probe cannot prove this — a healthy old container passes /health/live exactly as well as the new one
UI tag docker ps on the host ghcr.io/cortomaltese3/climate-lama-ui:v0.6.2 or later (measured 2026-08-19; it served bundle /assets/index-DuDXBjbE.js)
Alembic at head ./dc exec -T api alembic current matches alembic heads on that tag — 0086 on v0.16.0 (measured 2026-08-19)
Object store reachable a tile request returns 200, not 502 see step 5 — measured 200 for one COG of all six haz_types, 2026-08-12; not re-measured on 2026-08-19
No RISK_LOOKUP_COG_TIMEOUT_SECONDS override is present docker exec climate-lama-api-1 printenv RISK_LOOKUP_COG_TIMEOUT_SECONDS empty — #901 removed the override and it was still unset on 2026-08-19; see the superseded finding below. The code default budget applies and step 5's heatwave lookups pass on it
Disk df -h / on the host measure it — it has moved fast before

Dry-run findings, 2026-08-10 — read before you sit down

Every step below was rehearsed against production (then v0.12.0 = 27e99da) via the API before the sitting was booked, so you are not the one discovering these. Four defects were found and filed; three are fixed and now live — they shipped in v0.13.0 (596a6e5, Alembic 0082), deployed 2026-08-10 — and one is a data gap that still needs a decision (step 4).

The "fixed on trunk" wording in the table below therefore means fixed and deployed; re-verify against the running system rather than trusting either phrasing.

Step Rehearsal result Notes
1 was FAIL → fixed and live; criterion clarified All 21 active datasets had blank upstream_version and native_resolution; wildfire also had no provider. Fixed by #841, which stamps the fields at ingest and ships scripts/backfill_dataset_identity.py for existing rows; #827/PR #852 then restored the two EURO-CORDEX heatwave scenario labels. Re-verified 2026-08-11: three honest blanks remain on three rows, and nothing else — the wording of step 1 was tightened to name them, because ADR-067 deliberately leaves scenario NULL for a scenario-less source. See Honest blanks.
2 PASS Verified end to end, not merely counted — see the worked example below.
3 PASS The honest-framing rule holds: wildfire refuses with 422 E_INDICATOR_DATASET_NO_EAI, storm_europe with 422 E_HAZARD_DATASET_NO_EVENT_FREQUENCIES (ADR-069 — refuse rather than integrate a NaN). No fabricated EAI anywhere. One wart: heatwave refuses with a bare 422 E_VALIDATION and an empty detail, so it declines without saying why — acceptable but weaker than the step demands.
4 was BLOCKED → amended → river-flood half VERIFIED 9/9 on v0.14.0; two-hazard form REINSTATED 2026-08-19, coastal half NOT YET WALKED The old "≥2 scenario-conditioned computable hazards" could not be met while coastal_flood held no curve, so the step ran river flood's full 3 × 3 grid alone — see Step 4 was amended. #833 then seeded a coastal curve (option A, ADR-076), which is the recorded reinstatement condition: step 4 is two hazards again — see Step 4's two-hazard form was reinstated. The river-flood half stays verified (9/9 on v0.14.0); the coastal grid has still never been submitted and is not claimed — but its two preconditions are now met (ADR-076 deployed in v0.16.0, and northlane seeded — 15 impact-function rows, 3 coastal), and seven standalone coastal impact jobs completed on prod 2026-08-19. See Re-check, 2026-08-19 and 4b. Do not re-propose earthquake or wildfire — both are ruled out by physics, not by backlog. River-flood rehearsal detail — see Step 4 rehearsal below: it scored 6/9 before #861 and 9/9 after.

Why no second hazard qualified (prod, re-verified 2026-08-11) — and why coastal flood now does, as of 2026-08-19. Every other row still holds; only the coastal_flood row changed, and it changed because the curve claim was wrong, not because new data arrived:

haz_type active scenario axis curves verdict
river_flood 8 baseline, ssp2-4.5, ssp5-8.5 × 2030/50/80 3 (Huizinga, m) ✅ the one that works
coastal_flood 7 identical structure 3 (JRC/Huizinga, m, screening-grade) ✅ the second one that works, as of #833. The 2026-08-11 reading of this row — "Huizinga reuse is explicitly forbidden, fluvial calibration ≠ surge/wave" — was refuted against the source: JRC105688 §1.2 states the functions are "not associated with specific type of floods" and its headline validation case is a storm surge (§4.3.3.1, Sandy). See ADR-076. Curves are per-org rows: an org must run POST /v1/impact-functions/seed before its coastal grid resolves
earthquake 1 baseline only 4 (ESRM20, g) curve ✓, axis ✗ — ESHM20 is time-invariant; seismicity is not climate-conditioned, so no scenario axis exists to ingest. A matrix over it is one cell
tropical_cyclone 0 — 2 (Emanuel/Eberenz, m/s) curve ✓, data ✗ — synthetic track sets do not cover the Mediterranean basin. Verified at the raw-data level 2026-08-12 (#624 comment): the CLIMADA Data API does return country_iso3alpha=GRC-tagged tropical_cyclone records (17 active — IBTrACS random-walk 150as historical + RCP26/45/60/85, and STORM-based 300as baseline + 4 CMIP6-HighResMIP RCP8.5 variants), but 3 sampled HDF5 files spanning both methodologies (..._GRC_1980_2020, ..._rcp85_GRC_2060, TC_GRC_0300as_STORM) all have an empty intensity/data array — thousands of events and centroids over Greece, zero non-zero wind intensity anywhere. The API's country tag is a metadata-layer false positive; no track's footprint reaches Greek territory in any of these products. Ingesting them would add an all-zero layer, not a computable hazard
wildfire 1 NULL 6 (Lüthi, K) unit mismatch: dataset is FWI, curve is Kelvin. #481 searched and closed it — FWI is a fire-danger index, not a hazard intensity, so no FWI damage curve can exist in principle (corroborated independently by OS-Climate physrisk). The computable form (FIRMS brightness temperature — sourced by Phase 13 W3, not #412, which stays return-period scoped) is satellite-observed and therefore historical-only — it would land at baseline, exactly like earthquake
storm_europe 1 NULL 2 (Klawa Ulbrich, m/s) units match, but historical footprints only and no event frequencies (ADR-069 refuses)
heatwave 3 NULL in column, ssp* in the name (#827) 0 — indicator by design both barriers
5 was FAIL → fixed on trunk Every tile 502'd, for all 21 datasets — the proxy called a titiler route that no longer exists, and never sent a band selector (#831, fixed). Separately, the point lookup read all 100 bands of the heatwave COG and blew its 2 s budget, so heatwave reported cog_read_failed at every point (#838). Provenance itself is excellent — every answer names its dataset, source, licence, citation and selection rule.
6 PASS, with a caveat No side doors: every dataset traces to a pack. Only 5 of 21 carried a source_manifest.json, but per ADR-066 the other 16 kept their original provenance at staging/{ingest_job_id}/manifest.json — so scripts/backfill_source_manifests.py relocates the real captured records (real checksums) rather than reconstructing them.

Two more defects found while rehearsing, both fixed on trunk, neither visible as a step until you hit it:

  • No impact calculation could complete at all (#834). The first compute for any (hazard, exposure) pair died with invalid input syntax for type uuid: "". The RLS org GUC is transaction-local and reverts to the empty string after the preflight's mid-task commit, and the policy cast ''::uuid raises. Note the trap: the second attempt always succeeds, because the failed first run leaves the centroids assigned — so it reads as a flake rather than a defect.
  • Production had no platform admin at all (#828), so every /v1/admin/* route 403'd for everyone and the weekly poll's review queue could not be drained by anybody.

A point where step 2 actually demonstrates something

Most Greek coordinates return nulls from both river-flood datasets, which makes for a flat demo. Verified working side-by-side:

Thessaloniki / Axios delta — lat=40.60, lon=22.80

curl -fsS -H "Cookie: <session>" \
  "https://api.climate-lama.online/v1/risk/lookup?lat=40.60&lon=22.80&dataset_id=<id>"
dataset_id Dataset Non-null return periods
67854ef5-… JRC river flood Greece (national, historical baseline) 9 of 9 (1.63 m → 2.19 m)
a085c0b9-… Aqueduct river flood Greece — historical baseline 1980 (WATCH) 3 of 3

Both answer, the values differ, and provenance.dataset.name/.source names whichever you picked — which is exactly what step 2 asks you to show. Athens centre (37.98, 23.73) returns nulls from both and is a poor choice for this step, though it is a good one for earthquake (5 of 5 bands) and wildfire.

Re-check, 2026-08-12 — prod is v0.14.1 (0704467), UI v0.5.0

Everything above was written against v0.12.0–v0.14.0. It was re-measured against the running production stack on 2026-08-12 (DB read via a psql superuser connection, tiles via a direct titiler call from inside the api container). The substance holds; six things below it are now stale enough to mislead someone walking the sitting.

Confirmed still true. 21 active datasets across 6 haz_type values, in exactly the counts the tables above give (coastal_flood 7, earthquake 1, heatwave 3, river_flood 8, storm_europe 1, wildfire 1). Two source values each on river_flood and heatwave, one on the rest — so the "at least two" half of the goal still passes with no headroom. The honest-blanks table is exact. Every one of the 21 has a cog_path, and one COG per haz_type rendered a 200 PNG from titiler at z7/x72/y49 (over Greece). Impact functions per peril are unchanged: river_flood 3, earthquake 4, wildfire 6, storm_europe 2, tropical_cyclone 2, and zero for coastal_flood and heatwave.

Six corrections.

  1. GET /v1/hazards no longer defaults to every status. #873 / PR #876, shipped in v0.14.1, hides status='superseded' when ?status= is omitted. The "unfiltered returns 31 rows" claim above is dead: the table now holds 34 rows (21 active, 12 superseded, 1 NULL), and an unfiltered browse returns 22.
  2. That 22nd row is the demo fixture, and it is visible. JRC River Flood Greece (demo) (d67dbbb7-69af-4f4e-a775-89708199fc1f, source Climate-Lama reference fixture) carries status = NULL, which #876 deliberately treats as active — so it appears in the catalog browse, in the dashboard's River Flood group, and as a 9th option in the lookup's river-flood dataset picker. It has blank provider, upstream_version, native_resolution and citation, none of which are in the honest-blanks table. It is demoted, not banned (models/hazard.py::eligible_candidates), so it cannot win a lookup (#810) or a matrix cell (#631) against real catalog data. Consequence for the sitting: step 1 must be counted with ?status=active, or those four blanks read as a FAIL that isn't one; and step 6 should record the fixture as present but demoted rather than as a side door.
  3. Step 4's stated check is not observable where the step implies. provider_fallback is computed in core/compute_batch_service.py and then discarded — it is never persisted on the batch row and no endpoint returns it (it appears only in that module, its unit tests, and this file). Per-cell hazard_dataset_id is not on the batch response either: GET /v1/compute/impact/matrix/{id} cells carry only scenario_label, horizon_year, status, job_id, result_id, error_message, and the UI's matrix table shows scenario / horizon / status / EAD / AAI. The observable proxy is the result: GET /v1/results/{result_id} carries hazard_dataset_id. See the run sheet's step 4.
  4. Which dataset you seed the matrix with decides the outcome, and no step says so. The base dataset's ADR-048 provider is what #591/#861 rank the per-cell family against. Every verified 9/9 batch seeded with a085c0b9-… — Aqueduct river flood Greece — historical baseline 1980 (WATCH), provider World Resources Institute. Seeding with the JRC row instead makes the baseline column prefer JRC while the six SSP cells fall back across providers: precisely the #861 shape that scored 6/9.
  5. "9/9 verified" was verified against the demo exposure. Both the v0.14.0 batch (547c2833…) and a fresh one run today on v0.14.1 (f90d23f5-7ad0-4a70-be69-0c9616609eb8, 9/9 completed, all nine inside the Aqueduct family, baseline resolving the WATCH 1980 row at all three horizons) used exposure_dataset_id = b0a0cb68-… = LitPop Greece demo, 10 rows. A run against a real exposure (e.g. LitPop — Greece default asset-value exposure (150 arcsec, 2018), 7,896 rows) is not covered by that evidence and will be much slower cold. Use the demo exposure for the sitting unless you deliberately want the bigger claim.
  6. Superseded by #901 (2026-08-14) — step 5's heatwave leg no longer needs a hand-set env var. #885 and #901 are both closed. The root cause was pixel-interleaved deep-stack COGs causing ~100x read amplification, not an underpowered timeout budget; #901 fixed it by rebuilding the COGs band-interleaved. Post-rebuild reads measure 27.7–568.2 ms (vs 2374–5989 ms before). The RISK_LOOKUP_COG_TIMEOUT_SECONDS override was removed from .env.prod (backup: .env.prod.bak.20260814_pre901) and the code default budget now applies cleanly.

Two smaller notes: the honest-blanks table is three rows but four blank fields (windstorm-c3s-footprints-hist has two), and step 3's "(and TC if the stretch landed)" should be read as struck — tropical_cyclone has 0 active datasets and was ruled out at the raw-data level on 2026-08-12.

Re-check, 2026-08-19 — prod is v0.16.0 (a70cc3e), UI v0.6.2

Five releases landed between the re-check above and this one, and several of them move numbers this file quotes. Everything in this section was measured against the running production stack on 2026-08-19; nothing in it is inferred from a merged PR. The section above is kept as the dated record of what was true on v0.14.1, not as current guidance.

What is deployed. Backbone v0.16.0 = a70cc3ecbc7da9621ef83a098d732fb385bd28ef (deploy-prod.yml run 32245322810), confirmed by scripts/verify-deploy.sh v0.16.0 — the identity check, which is the proof that matters, because a healthy old container passes a liveness curl just as well. UI v0.6.2, serving bundle /assets/index-DuDXBjbE.js. Alembic head 0086. RISK_LOOKUP_COG_TIMEOUT_SECONDS is still unset on the api container, as #901 left it.

What shipped since 2026-08-12 and is now live — the three releases that move numbers this file quotes, out of the five tagged in that window.

Release Change Why the sitting cares
v0.15.0 #934 / ADR-073 — every address-level band reads the point; a resolved cube slice is a band input Point-banded ladders that could never fire now can
v0.15.0 #935 / ADR-074 — an absent cell of a count-indicator peril reads as zero (heatwave only) A heatwave point that used to answer no_bandable_reading can now band at the bottom of the ladder; the card gains intensity.band_value_source naming which read produced the band
v0.15.0 #932 / ADR-075 — a run-backed surface carries its design-return-period intensity into each cell's metrics Rollup banding, previously dead for all nine seeded schemes, is alive
v0.15.1 #925 — migration 0086 recalibrates the climatological heatwave band edges Any heatwave band label recorded in this file before 2026-08-16 is not comparable
v0.15.1 #942 — earthquake cards band at the design return period, RP 476 Earthquake band values predating it are not comparable
v0.15.1 #943 — resave_run_surfaces, so stored surfaces can be rebuilt from their own results The mechanism behind proof 1 below
v0.16.0 #833 / ADR-076 — coastal flood is computable, screening-grade Step 4's second hazard exists and is live; see 4b
v0.16.0 #856 — heatwave posture decided (deliberately deferred monetisation, not absent literature) Step 3's honest-framing wording
v0.16.0 #891 — pack scripts take --org-slug Ops only; no step depends on it

Prod proofs run 2026-08-19 — you may rely on these.

  1. Surfaces carry their metric. resave_run_surfaces --apply on northlane re-saved 24 surfaces. Their cells now carry flood_depth_m on 85,574 cells and pga_g on 10,000 — the ADR-075 metrics keys the rollup ladders band from.
  2. The admin-unit rollup answers. Attica (GADM 4.1 level 1, boundary e7f12fe5-4c95-4058-9560-14df355f53f0 — the only GRC boundary loaded, so it is the rollup you can demo) returns a banded river_flood score of 2 "Very low" at 0.0657 m, aggregation points_weighted_cell_mean, over 28 cells from 2 surfaces; earthquake's headline score is 6.
  3. Athens earthquake point lookup (37.9838, 23.7275) → band score 6 "Elevated", band_return_period 476.0, band_value 0.2477 g. The RP is #942's design-RP override, so a pre-v0.15.1 reading of the same pin is not comparable to this one.
  4. Coastal flood is seeded, and it computes. northlane holds 15 impact-function rows, org-scoped, three of them coastal (JRC coastal flood Europe buildings is the default, unit m). Do not read that against the 2026-08-12 per-peril counts above and conclude rows vanished: those were DB-wide across both orgs, this one is northlane alone, and 15 is exactly _BUILTIN_FUNCTIONS' post-#833 size. All seven active Aqueduct coastal datasets report indicator: false, eai_grade: screening. Seven impact jobs completed, one per coastal dataset, against exposure 69e03607-… (LitPop):
Scenario Horizon AAI
baseline — €22.9 M
ssp2-4.5 2030 / 2050 / 2080 €29.1 M / €34.0 M / €45.0 M
ssp5-8.5 2030 / 2050 / 2080 €29.9 M / €35.6 M / €50.4 M

Seven coastal surfaces were written, carrying coastal_flood_depth_m on 500 cells.

Two coastal caveats to carry into the sitting. Record them; do not chase them mid-sitting.

  • The Attica rollup for coastal_flood reports no_cells_in_scope under the default baseline / horizon 0 key. The seven coastal surfaces exist (proof 4); the default rollup key does not select them. River flood and earthquake both roll up fine on the same boundary.
  • A Piraeus point lookup (37.94, 23.64) is available: true but no_bandable_reading. Aqueduct's coastal inundation pixels are sparse — the same class as the river-flood sparsity note above, where most Greek coordinates return nulls from both river-flood datasets. So for step 5's coastal leg, pick a coordinate that is actually inundated in the COG you selected rather than an arbitrary waterfront point, or you will demo a blank card and read it as a defect.

What this re-check did not measure — re-assert, do not inherit. The 21-active-dataset count and the per-type counts were not re-counted beyond coastal_flood = 7. No tiles were re-rendered. The two cosmetic UI defects listed in the run sheet were recorded against UI v0.5.0 and have not been re-checked on v0.6.2. And no heatwave band was read on v0.15.1+ — see steps 3 and 5.

The six steps

Each row is one step of the phase goal. Record PASS/FAIL per row with evidence. The per-step UI procedure — which page, which option, what you should see — is in The UI run sheet below.

# Step Do this Expected result
1 Browse ≥6 hazard types Open the catalog in the UI; cross-check GET /v1/hazards?status=active&limit=200 — status=active is required since #876; without it the demo fixture joins the list ≥6 distinct haz_type. Every entry carries source, license, citation, version, resolution and scenario metadata — no blanks except the three the sources genuinely do not publish, listed under "Honest blanks" below. A blank outside that list is a FAIL; an invented value for anything on it is a worse FAIL
2 ≥2 datasets for the same peril, ×2 perils For two perils (e.g. river flood: JRC vs Aqueduct; heatwave: EURO-CORDEX vs ERA5-HEAT), pick each alternative and run the same lookup The result changes with the choice, and the provenance shown names the dataset you picked. Walk this in the UI — the per-hazard dataset picker shipped in ui v0.5.0. The API path (GET /v1/hazards?haz_type=<t>&source=<s>, compare by dataset_id) is still the fallback if the UI misbehaves
3 Compute on every computable peril; indicators for the rest Run an impact computation for RF / WS / EQ (~~and TC if the stretch landed~~ — TC has 0 active datasets, do not attempt). For heatwave, drought and wildfire-via-FWI, open the score/indicator layer Computable perils return EAI/EP. Indicator perils show a score layer and are excluded from EAI — and the UI says so explicitly. A fabricated EAI on an indicator peril is a FAIL, not a nicety
4 Full scenario matrix on two hazards — amended 2026-08-11 to one hazard, reinstated 2026-08-19, see below Run the matrix over the complete baseline / ssp2-4.5 / ssp5-8.5 × 2030 / 2050 / 2080 grid twice: once on river flood, once on coastal flood. Coastal flood needs POST /v1/impact-functions/seed to have run in the org first (curves are per-org rows) All 9 cells return values on each hazard; no cell reports missing centroids, and no cell silently replays another cell's dataset — check the per-cell hazard_dataset_id differs across scenarios. That id is not on the batch response or in the UI table (re-check note 3): read it off each cell's result, GET /v1/results/{result_id}. Expect 7 distinct datasets over 9 cells on river flood — the baseline row repeats across the three horizons by design. The coastal results carry the amber "Screening-grade EAI" pill (ADR-076) — an unlabelled coastal EAI is a FAIL. See wave-0 trap 2 — the first run per (hazard, exposure) pair is slow because it assigns centroids on demand
5 Tiles + attributed lookups For every dataset on the map, pan/zoom to confirm tiles render; run a point lookup on each Tiles 200, not 502/404. Every lookup answer names the dataset it came from. The provenance chip shipped in ui v0.5.0, so check it in the UI; the API field is also available
6 One pipeline, no side doors For each dataset, confirm it arrived via the ingest pipeline from a documented manifest Every dataset traces to a re-runnable source manifest. No script wrote to the DB directly. scripts/seed_demo.py output does not count as catalog content — if a seeded demo row is answering real queries, that is a FAIL (this is exactly what #631 was)

Step 4 was amended on 2026-08-11 — from "≥2 hazards" to one full grid

This is an amendment with a recorded rationale, not a waiver. The distinction matters, because this file's own closing rule forbids waiving a step, and Phase 8 exited once by doing exactly that.

What changed. Step 4 read "Run the matrix over scenario-conditioned data for two different hazards." It now runs the complete 3 × 3 grid on river flood — nine cells, every scenario against every horizon — instead of a thinner slice across two perils.

Why the ≥2-hazard clause was dropped. It was testing two different things at once, and only one of them belongs in step 4:

  1. Does the scenario-matrix machinery work end to end? — "all cells return values, no cell reports missing centroids." One hazard proves this completely, and the full 3 × 3 grid proves it harder than two perils × two cells ever did. Nine cells, three distinct scenario datasets, three horizons each.
  2. Is the catalog broad? — that is a breadth claim, and steps 1, 2, 3 and 5 already carry it: ≥6 hazard types browsed, two providers compared for the same peril, every computable peril computed and every indicator peril honestly refused, all 21 datasets tiled and attributed. Asserting it a fifth time inside step 4 was redundant.

So the amendment removes a redundant breadth assertion from the wrong step. The claim under test in step 4 — that the matrix machinery works — is not weakened at all.

Why not just fix the data instead. Because no second hazard can qualify without new science, not new engineering. The per-peril table in the dry-run findings above is the evidence; the two candidates that keep looking promising are both ruled out by physics:

  • Earthquake is computable now (engine#30 closed, 4 ESRM20 curves), but ESHM20 is time-invariant — seismicity is not climate-conditioned, so there is no scenario axis to ingest. A matrix over it is one cell.
  • Wildfire cannot take a curve at all on FWI: #481 ran that search and closed it — FWI is a fire-danger index, not a hazard intensity (corroborated independently by OS-Climate physrisk). Its computable form (FIRMS brightness temperature, #412) is satellite-observed, so it would land at baseline — the same one-cell shape as earthquake.

Coastal flood is the one real path back, and it is a single missing artifact: seven active datasets already carry the identical baseline / ssp2-4.5 / ssp5-8.5 × 2030 / 2050 / 2080 structure, and only the damage curve is absent. #833 owns that, and it is explicitly not satisfiable by reusing the fluvial Huizinga curve — #621 adjudicated that and rejected it, because surge and wave are a different physical process.

Reinstatement condition. When #833 lands a defensible coastal curve, restore the original two-hazard wording and re-walk step 4 across river flood and coastal flood. Until then this amendment stands, and the reason travels with it.

Superseded on 2026-08-19 — the condition above was met. This section is kept verbatim as the dated record of the amendment, not as current guidance. Two of its claims are now known to be wrong and are corrected in the section immediately below: "no second hazard can qualify without new science" (coastal flood qualified, and it took no new science — only a corrected reading of a source already in the tree), and "#833 is explicitly not satisfiable by reusing the fluvial Huizinga curve" (the publication scopes its own functions to generic inundation, JRC105688 §1.2). Read Step 4's two-hazard form was reinstated for what step 4 actually requires today.

Step 4's two-hazard form was reinstated on 2026-08-19

The condition above was met. #833 landed a coastal-flood depth-damage curve (owner decision, option A — ADR-076), so step 4's criterion in the table above is back to its original two-hazard wording and is walked across river flood and coastal flood.

What the curve is, and why the earlier "explicitly forbidden" bar fell. It is the same published JRC/Huizinga (2017) Europe depth-damage function the river-flood curves already carry, seeded under coastal_flood at intensity_unit = "m". That is not the Huizinga reuse the amendment above forbade — it is the reuse the source itself authorises: "Because the data used in this study comprise both fluvial and marine flooding, the damage functions constructed are not associated with specific type of floods" (JRC105688 §1.2, p.3), and the report's headline validation case is a storm surge (§4.3.3.1, Hurricane Sandy). The "fluvial calibration ≠ surge/wave" claim this file recorded on 2026-08-11 came from ADR-059, whose keyword sweep searched for coastal / storm surge / salt while the report says "marine flooding" and "storm Sandy". ADR-058 and ADR-059 both carry superseding notes.

The EAI is screening-grade (EaiGrade.SCREENING, ADR-076) — depth is the only flood parameter these functions model, for any flood type — so every coastal result must render the amber pill, exactly as earthquake's does. That label is part of the step's pass condition.

What is not claimed here. The coastal 3 × 3 grid has still never been submitted. Three things stood between this note and a PASS when it was written, and none of them was code; two are now done. Status measured on prod 2026-08-19:

  1. ~~Deploy. The curve is on trunk; prod picks it up on the next release.~~ Done — shipped in v0.16.0 (a70cc3e), verified by scripts/verify-deploy.sh.
  2. ~~Seed the org.~~ Done — northlane holds 15 impact_functions rows, three of them coastal, and all seven active coastal datasets report eai_grade: screening. The rows are per-organization, so the empty legacy default org is still deliberately not seeded. Before the seed, a coastal matrix submission is refused with the generic seeding-gap E_VALIDATION — not the old 422 E_INDICATOR_DATASET_NO_EAI, which no longer describes this peril.
  3. Run the grid. Still outstanding, and it is the one that carries the PASS. Seven standalone impact jobs — one per coastal dataset — completed on 2026-08-19 with the AAIs in 4b, and seven coastal surfaces were written (coastal_flood_depth_m, 500 cells). That proves the curve resolves and computes; it does not prove the per-cell matrix resolver picks the right dataset in each of the nine cells, which is the only thing step 4 is actually testing. Walk it.

Expect the same traps the river-flood half hit: the first run per (hazard, exposure) pair is slow because it assigns centroids on demand, and a provider_fallback = true anywhere in the grid is a regression, not a wart.

Step 4 rehearsal: 6/9, then 9/9

Amending the criterion was not enough — the amended step still had to be run. Doing that on production surfaced a defect that resolution alone could never have shown, and the lesson generalises well beyond step 4.

A cell resolving tells you nothing about whether it computes. The first rehearsal resolved cleanly — 9 of 9 cells found a dataset, provider_fallback duly reported on three of them — so it looked fine. Running the actual batch:

baseline  2030/2050/2080  FAILED      <- the three cross-provider cells
ssp2-4.5  2030/2050/2080  completed
ssp5-8.5  2030/2050/2080  completed

all three with Every assigned exposure row sits outside this hazard dataset's grid or on a zero-hazard cell — a message about exposure, when the actual cause was that the matrix had silently swapped provider on one column. Resolution is a catalog lookup; compute is a grid ∩ exposure intersection, and they can disagree. #861 fixed the swap; #871 tracks the misleading message that remains when a fallback does occur.

Verified after the fix, on v0.14.0 (472a8d4), batch 547c2833-2c16-47ea-aa21-a2f14d9a99b6: 9/9 cells completed, every one with a result, and all nine resolved inside the Aqueduct family — the baseline column no longer substitutes JRC.

So when you walk step 4, expect all nine cells green and every cell's dataset to be an Aqueduct row. A provider_fallback = true anywhere in the grid is now a regression, not a wart.

Two practical notes for the sitting:

  • The first run for a given (hazard, exposure) pair is slow — it assigns centroids on demand. A warm pair settles in about 30 seconds; a cold one takes minutes. Don't read the cold-run latency as a fault.
  • GET /v1/compute/impact/matrix/{id} returns per-cell result references, not EAI values. eai: null there is by design — fetch the result to see the number.

Honest blanks — the three step 1 must tolerate

Step 1 says "no blanks", and read literally that is now too strict: ADR-067 and #808 deliberately leave scenario NULL for a source that declares no scenario axis, because inventing a label there is the falsification the platform exists to avoid. Demanding a value would be demanding the platform violate its own ADR.

Verified against production 2026-08-11 — across all 21 active datasets, exactly three fields on three rows are blank, and every one is honest:

Row Blank field Why it is honest
heatwave-ecde-europe scenario ERA5-derived observational reanalysis. It is not a scenario; it is what happened
wildfire-fwi-historical-greece-20230801 scenario Historical FWI. Same reason
windstorm-c3s-footprints-hist scenario, upstream_version Historical footprints; and the C3S reanalysis retrieval declares no version input at all

Everything else — source, license, citation, upstream_version, native_resolution, provider — is populated on all 21 rows. So the check to run is: any blank not in this table is a FAIL, and any value that appears where this table says one should not exist is a worse FAIL — it means something fabricated a label.

Note that the two EURO-CORDEX heatwave rows are no longer in this list. #827 (PR #852) restored their archive-member labels, so they now carry ssp2-4.5 and ssp5-8.5 — the label was known and was being discarded, which is a different thing from a source that never had one.

Honest framing you must not soften in step 3

From phase-9-data-foundation.md: RF / WS / TC / EQ have machine-readable damage curves → full EAI/EP. Add CF to that list as of 2026-08-19 (#833, ADR-076): coastal flood is computable on the same published JRC/Huizinga functions river flood uses, and — like earthquake — at screening grade, so its EAI must carry the grade label. Those two are the screening-grade pair; RF / WS / TC are not. Heatwave and drought have no published structural-damage curves — they are hazard-indicator perils: ingested, tiled, scored, shown on the score card, and excluded from EAI. Amended 2026-08-19 (#856): that exclusion is a deliberately deferred monetisation, not an absent literature — heat has published health and labour-productivity functions (physrisk; Stalhandske et al. 2022, NHESS, on CLIMADA) and drought has crop-yield ones; what heat has no curve for is structural damage. Say it that way, not as "no published curve at all". Wildfire ships as an indicator peril on the FWI packs (#481): FWI is a weather-only fire-danger index that no damage function can consume in principle. Computable wildfire is gated on intensity-grade footprints under #412 (stretch). Earthquake ships screening-grade via ESHM20.

Earthquake carried a cross-repo gate — now tripped and satisfied. climate-lama-ui#109 (render eai_grade / eai_grade_note) had to land **before

617's earthquake data reached prod**, or the UI would present screening-grade EAI as

if it were computed-grade. Both happened in the safe order: ui#109 closed 2026-08-07, ESHM20 reached prod 2026-08-10. Still check the grade label renders when you walk step 3 on EQ — that is what the gate was protecting, and it is now the only part of it you can still get wrong.

The UI run sheet

Written against prod as it stood on 2026-08-12 — backbone v0.14.1 (0704467), UI v0.5.0, 21 active datasets. Every dataset id, option label and expected count below was read off the running system, not off an earlier version of this file. Re-assert the preconditions table first; if the version moved, re-read the counts before trusting the numbers here.

Partially re-measured on 2026-08-19 against backbone v0.16.0 (a70cc3e) and UI v0.6.2. Steps 3, 4b and 5 below carry the new readings inline; everything else in this run sheet still dates from 2026-08-12. Read Re-check, 2026-08-19 for what was and was not re-measured — in particular, no heatwave or earthquake band label from before 2026-08-16 is comparable to what you will read on the day.

URLs. UI https://climate-lama.online · API https://api.climate-lama.online.

Log in at /login — three fields:

Field Value
Organization slug northlane
Email develama@protonmail.com
Password the owner's

Do not use demo@example.com. That account belongs to acme-demo, which was soft-deleted 2026-08-11; all 34 hazard rows live in northlane. develama is org_admin and platform admin, so the /admin nav entry is reachable — that is the account the admin-gating side-check needs too.

Two cosmetic defects recorded on UI v0.5.0. Both are unverified on v0.6.2 — check whether they are still there rather than expecting them, and either way record and move on.

  • The provenance chip doubled the "v" — vv3.1.1, vv2, vESHM20 — because most rows' upstream_version already carries the provider's own v. climate-lama-ui#116 was fixed on UI main (PR #117, merged 2026-08-12) and prod now runs v0.6.2, which is after that merge — so this is very likely already gone. Confirm on the day; it was not re-measured on 2026-08-19.
  • heatwave and earthquake rendered as raw enum strings everywhere in the UI. Neither had an entry in the lookup or glossary label maps; the other four types did. Not a step failure — the metadata is present, only the display name is missing — but it was the most visible presentability gap in the whole walk, so it is worth a deliberate look on v0.6.2.

One timing rule that applies to steps 3 and 4. The first compute for any (hazard dataset, exposure) pair assigns centroids on demand: the map shows "Assigning centroids…" and a cold pair takes minutes; a warm one settles in about 30 seconds. Do not read cold-run latency as a fault, and do not start a matrix on a pair you have never computed before if you are short on time.


Step 1 — Browse ≥6 hazard types

Page: /datasets → Hazards tab (the default).

Do:

  1. Leave every filter on "All …". Read the count in the top-right of the filter bar.
  2. Open the Hazard filter and enumerate its options.
  3. Click through one row per hazard type and read its attribution chip (the grey pill under each list entry). Hover it — the citation is the tooltip, and it is a real link when the row has a source_url.
  4. Cross-check the API — with status=active:
    curl -fsS -H "Cookie: <session>" \
      "https://api.climate-lama.online/v1/hazards?status=active&limit=200" \
    | jq -r '.data[] | "\(.haz_type)\t\(.source)"' | sort | uniq -c
    

Expect:

  • The Hazard filter offers 6 options: River Flood, Coastal Flood, earthquake, heatwave, Storm (Europe), Wildfire (the middle two unlabelled, see above).
  • The unfiltered list reads 22 datasets, not 21 — the extra is JRC River Flood Greece (demo), the seeded fixture (status = NULL, source Climate-Lama reference fixture). That is expected, not a finding. It is demoted out of every real resolution path; see step 6.
  • The API cross-check returns 21 rows over 6 types: coastal_flood 7, earthquake 1, heatwave 3, river_flood 8, storm_europe 1, wildfire 1 — and two distinct source values for river_flood (JRC v3.1.1 · WRI Aqueduct v2) and for heatwave (ECDE/ERA5 · EURO-CORDEX), one for the rest.
  • Every chip shows source · license.

What the UI cannot show you, so do it against the API. The /datasets page renders name, created date, hazard type, region and the source/license chip — it does not render upstream_version, native_resolution or scenario. Those three are part of step 1's criterion, so verify them here:

curl -fsS -H "Cookie: <session>" \
  "https://api.climate-lama.online/v1/hazards?status=active&limit=200" \
| jq -r '.data[] | [.name,.provider,.upstream_version,.native_resolution,.scenario]|@tsv'

Expect exactly four blanks, on three rows — heatwave-ecde-europe.scenario, wildfire-fwi-historical-greece-20230801.scenario, and windstorm-c3s-footprints-hist.scenario and .upstream_version. Every other cell populated on all 21. A blank outside that set is a FAIL; a value where that table says there should be none is a worse FAIL. (Partial UI coverage does exist: the lookup's provenance chip shows version, and its dataset-picker option labels show provider, resolution and scenario — see step 2.)

Verdict: PASS if 6 types, 21 active rows, the source split above, and only the four honest blanks.


Step 2 — ≥2 datasets for the same peril, on two perils

Page: /lookup.

Do — peril 1, river flood:

  1. Drop a pin at Thessaloniki / Axios delta, lat 40.60, lon 22.80 (address search, or click the map). Athens centre returns nulls from both river-flood datasets and makes a flat demo — use it for earthquake and wildfire instead.
  2. On the River flood card, open the Dataset select (it appears only where the peril has more than one catalogued dataset). It defaults to "Auto (recommended)", which sends no dataset_id at all.
  3. Pick JRC river flood Greece (national, historical baseline) — id 67854ef5-8052-435d-9828-1a1015146654. Read the values table and the provenance chip.
  4. Switch the same select to Aqueduct river flood Greece — historical baseline 1980 (WATCH) — id a085c0b9-09fc-40fd-aa80-6fa0f9451ac4. Read them again.

Expect: JRC answers 9 of 9 return periods (≈1.63 m → 2.19 m); Aqueduct answers 3 of 3. The numbers differ, and the provenance chip changes to name whichever you picked — JRC… · Copernicus EMS/JRC · vv3.1.1 versus Aqueduct… · World Resources Institute · vv2 — or v3.1.1 / v2 with a single v if ui#116 shipped in v0.6.2, which is likely; see the run-sheet defect note above. Either spelling passes this step — it is testing that the chip renames itself, not how it spells the version.

Do — peril 2, heatwave: same pin, the heatwave card, whose Dataset select offers three:

Option id What it is
heatwave-ecde-europe 32fcd25e-… ERA5 reanalysis, no scenario
heatwave-euro-cordex-europe-ssp2-4.5 a1f804a6-… projection, SSP2-4.5
heatwave-euro-cordex-europe-ssp5-8.5 c1f3c898-… projection, SSP5-8.5

Expect: each answers with a different value, the chip renames itself, and every one of them also carries the "Indicator — no EAI" badge — which is step 3's evidence, visible here.

The picker will list 9 river-flood options, not 8 — the demo fixture is the ninth. Picking it deliberately is fine (it is a demotion, not a ban, so an explicit pin is honoured); it just is not what step 2 is asking you to compare.

Fallback if the picker misbehaves: GET /v1/hazards?haz_type=river_flood, then GET /v1/risk/lookup?lat=40.60&lon=22.80&dataset_id=<id> per dataset, comparing provenance.dataset.

Verdict: PASS if, on both perils, the answer changes with the choice and the provenance names the dataset you picked.


Step 3 — Compute on every computable peril, indicators for the rest

Two surfaces. Compute is on the dashboard; the indicator half is on the lookup.

3a — Compute. Page: / (dashboard). Left panel, top to bottom: Exposure → Hazard → Impact function → Measures → Scenario / Time horizon → Run (pinned at the bottom).

Run it four times — row 4 is new as of v0.16.0:

# Hazard section Dataset Impact function Exposure
1 River Flood Aqueduct river flood Greece — historical baseline 1980 (WATCH) JRC flood Europe buildings (the default) LitPop Greece demo
2 earthquake ESHM20 earthquake hazard — Greece (mean PGA…) any of the 4 ESRM20 curves LitPop Greece demo
3 Storm (Europe) windstorm-c3s-footprints-hist Klawa Ulbrich 2003 WS buildings EUR LitPop Greece demo
4 Coastal Flood the baseline member of the Aqueduct coastal family (GET /v1/hazards?haz_type=coastal_flood&status=active lists the seven) JRC coastal flood Europe buildings (the default) LitPop Greece demo

Expect: runs 1 and 2 complete and return EAD/AAI with an impact layer on the map; run 2 additionally shows the amber "Screening-grade EAI" pill (that is the ui#109 gate this step exists to protect — check it renders). Run 3 is expected to be refused: ADR-069 declines a hazard whose events carry no annual frequency (E_HAZARD_DATASET_NO_EVENT_FREQUENCIES) rather than integrating a NaN. A storm_europe run that returns a number is a FAIL, not a success.

Coastal flood became runnable on 2026-08-19, and is live — 7 active datasets, and since #833 three seeded JRC/Huizinga curves in m (ADR-076), shipped in v0.16.0. It is now a computable, screening-grade peril: run it like river flood — row 4 of the table above — and expect an EAD/AAI plus the amber "Screening-grade EAI" pill.

Precondition — already satisfied on northlane, but re-assert it. Impact-function rows are per-organization, so POST /v1/impact-functions/seed has to have run in the org after the deploy carrying #833. It has: measured 2026-08-19, northlane holds 15 impact-function rows, three of them coastal, with JRC coastal flood Europe buildings as the default. Confirm with GET /v1/impact-functions?haz_type=coastal_flood. If the select is empty and Run is refused with "No impact functions registered for hazard type 'coastal_flood'", that is the seeding gap, not the old indicator refusal, and the fix is the seed call.

3b — Indicators. Page: /lookup, any Greek coordinate (Athens centre 37.98, 23.73 works well — 5 of 5 earthquake bands, and wildfire answers).

Expect: the heatwave and wildfire cards each render values plus the grey "Indicator — no EAI" badge, and the earthquake card renders the amber "Screening-grade EAI" pill whose tooltip carries the backbone's own eai_grade_note. Click "Why this score?" on each to see the scheme / surface / dataset provenance sections. That is the step's criterion and it has not moved — what a card says has.

The band each card shows is produced differently since v0.15.0/v0.15.1. Do not carry a number forward from this file's older sections.

  • Heatwave. Three changes stack on this one card: ADR-073 (#934) made every address-level band read the point; ADR-074 (#935) made an absent cell of this count-indicator peril read as zero rather than as a gap, so a pin that used to answer no_bandable_reading can now band at the bottom of the ladder, and the card carries intensity.band_value_source naming which read produced it; and migration 0086 (#925) recalibrated the climatological band edges outright. A v0.14.1 heatwave reading is therefore not comparable on the value, on the band, or on whether a band appears at all. Measure it on the day and record what you see. What has not changed: heatwave is still excluded from EAI and must still carry the "Indicator — no EAI" badge.
  • Earthquake. #942 made the card band at the design return period, RP 476. Measured on prod 2026-08-19 at 37.9838, 23.7275 (Athens centre): band score 6 "Elevated", band_return_period 476.0, band_value 0.2477 g. Expect that shape; an RP other than 476 on an earthquake card is the thing to flag.

Known wart, already recorded: attempting a heatwave compute refuses with a bare 422 E_VALIDATION and an empty detail — it declines without saying why. Acceptable, weaker than the step asks. (Recorded on v0.14.1; not re-measured on 2026-08-19.)

Verdict: PASS if every computable peril computes, every indicator peril is labelled as one, storm_europe refuses honestly, and no fabricated EAI appears anywhere.


Step 4 — Full scenario matrix on two hazards

Page: /matrix. Two grids: 4a river flood (below, verified) and 4b coastal flood (after it, never yet walked).

4a — River flood

Do:

  1. Hazard dataset: Aqueduct river flood Greece — historical baseline 1980 (WATCH) (a085c0b9-09fc-40fd-aa80-6fa0f9451ac4). This choice is load-bearing — the base dataset's provider is what the per-cell resolver ranks families against, so seeding with the JRC row instead reproduces #861's cross-provider split. Do not improvise here.
  2. Impact function: JRC flood Europe buildings (the default).
  3. Exposure: LitPop Greece demo — the exposure both verified 9/9 batches used.
  4. Scenarios: tick Baseline, SSP2-4.5, SSP5-8.5 (SSP2-4.5 is pre-ticked; untick nothing else).
  5. Horizons: tick 2030, 2050, 2080 (2050 is pre-ticked).
  6. Submit. The page switches to a 9-badge progress grid, then to a results table.

Expect: all 9 badges green (completed) — no red failed, no amber unresolved — and a results table with 9 rows each carrying an EAD and an AAI. Baseline rows will share a value across the three horizons; that is correct, they resolve the same 1980 WATCH row.

The dataset check the UI cannot do. Neither the batch response nor the results table carries the per-cell hazard_dataset_id, and provider_fallback is never returned by any endpoint. Read it off the results:

curl -fsS -H "Cookie: <session>" \
  "https://api.climate-lama.online/v1/compute/impact/matrix/<batch_id>" \
| jq -r '.data.cells[] | "\(.scenario_label)\t\(.horizon_year)\t\(.status)\t\(.result_id)"'
# then, per result_id:
curl -fsS -H "Cookie: <session>" \
  "https://api.climate-lama.online/v1/results/<result_id>" | jq -r .data.hazard_dataset_id

Expect 7 distinct dataset ids over the 9 cells — one Aqueduct baseline repeated across the three horizons, plus three SSP2-4.5 rows and three SSP5-8.5 rows — and every one of them an Aqueduct row. A JRC id anywhere in the grid is the #861 regression.

Reference run, prod v0.14.1, 2026-08-12: batch f90d23f5-7ad0-4a70-be69-0c9616609eb8 — 9/9 completed, baseline column resolving Aqueduct … historical baseline 1980 (WATCH) at 2030/2050/2080, SSP columns resolving their own year-matched rows.

Verdict (4a): PASS if 9/9 completed and all nine resolved inside the Aqueduct family.

4b — Coastal flood

Reinstated on 2026-08-19 by #833 (ADR-076), and live on prod since v0.16.0. The 9-cell matrix itself has still never been submitted — treat the procedure below as unwalked. What has been run, on prod 2026-08-19, is the layer underneath it: seven standalone impact jobs, one per coastal dataset. See the reference numbers below.

Precondition — already satisfied on northlane; re-assert, do not assume. Impact-function rows are per organization, so an analyst on the org must have called POST /v1/impact-functions/seed after the deploy carrying #833. Measured 2026-08-19: northlane holds 15 impact-function rows, three of them coastal, and all seven active Aqueduct coastal datasets report indicator: false, eai_grade: screening. Confirm with GET /v1/impact-functions?haz_type=coastal_flood — expect the three JRC coastal flood Europe … rows, JRC coastal flood Europe buildings being the default. Without the seed the Impact-function select is empty and the submission is refused with the generic "No impact functions registered for hazard type 'coastal_flood'".

Do:

  1. Hazard dataset: an Aqueduct coastal row (GET /v1/hazards?haz_type=coastal_flood&status=active lists the seven). Pick the baseline member of the family, for the same reason 4a does: the base dataset's provider is what the per-cell resolver ranks families against.
  2. Impact function: JRC coastal flood Europe buildings (the default).
  3. Exposure: LitPop Greece demo — the same exposure 4a used.
  4. Scenarios: tick Baseline, SSP2-4.5, SSP5-8.5.
  5. Horizons: tick 2030, 2050, 2080.
  6. Submit.

Expect: 9 green badges and 9 rows carrying an EAD and an AAI, each labelled with the amber "Screening-grade EAI" pill (ADR-076 — an unlabelled coastal EAI is a FAIL, the same bar ui#109 set for earthquake). Read the per-cell hazard_dataset_id off each result exactly as in 4a; every one should be an Aqueduct coastal row, and a provider_fallback is a regression.

If cells fail rather than refuse, expect the same failure modes 4a hit and not new ones: centroid assignment on the first run per (hazard, exposure) pair, and the exposure-shaped message that actually means a grid ∩ exposure miss (#871).

Reference numbers from the layer underneath, prod 2026-08-19. The grid was not submitted, but seven impact jobs completed, one per coastal dataset, against exposure 69e03607-… (LitPop) — which is the same computation each matrix cell performs, minus the per-cell resolution the matrix adds. Seven coastal surfaces were written, carrying coastal_flood_depth_m on 500 cells.

Scenario Horizon AAI
baseline — €22.9 M
ssp2-4.5 2030 / 2050 / 2080 €29.1 M / €34.0 M / €45.0 M
ssp5-8.5 2030 / 2050 / 2080 €29.9 M / €35.6 M / €50.4 M

Use these as a shape check, not as an equality check — a different exposure gives different absolute numbers. What should hold in the grid: every cell returns a number, the baseline row repeats across its three horizons, and AAI rises monotonically with horizon within each SSP column and is higher under ssp5-8.5 than ssp2-4.5 at the same horizon. A cell that inverts that ordering is worth a look before you record a PASS.

Verdict (4b): PASS if 9/9 completed, all nine resolved inside one Aqueduct coastal family, and every result renders the screening-grade label.

Verdict (step 4): PASS only when both 4a and 4b pass.


Step 5 — Tiles render, and every lookup answer names its dataset

5a — Tiles. Page: / (dashboard). The map shows one hazard layer at a time — the dataset currently selected in the Hazard section — so "every dataset" means stepping the selection through all 21. The Layers panel sits bottom-right: a Hazard toggle that is disabled and greyed out whenever the dataset has no renderable layer, plus an opacity slider once it is on.

Do: for each hazard type, select a dataset, confirm the Hazard toggle is enabled, turn it on, and pan/zoom over Greece until tiles paint. Watch the browser network tab: requests go to /v1/hazards/{id}/tiles/{z}/{x}/{y}.png.

Expect: 200s, not 502/404. The route that made every tile 502 (#831) is fixed and deployed; one COG per haz_type was measured rendering a 200 PNG on 2026-08-12. Two caveats worth knowing before you call something broken: heatwave and storm_europe are Europe-wide grids at 0.25°/0.1°, so at low zoom their tiles are legitimately near-empty (they still return 200); and the demo fixture's COG lives under the retired org's key prefix by design, so its tile URL looks wrong and is not.

5b — Attributed lookups. Page: /lookup. Drop pins at 40.60, 22.80 (Thessaloniki) and 37.98, 23.73 (Athens).

Expect: every hazard card — including the "Not built here" ones — carries a provenance chip naming its dataset, provider and version, with license and citation in the tooltip. "Why this score?" opens the drawer with the full attribution and a real citation link. That is the step's criterion, and attribution is what it tests — not the band values, which have moved.

Choose the coastal pin deliberately. Coastal flood was always in this step — 7 of the 21 datasets, all tiling — what v0.16.0 changed is that its cards now carry the screening-grade pill. What has not changed is that Aqueduct's coastal inundation pixels are sparse in exactly the way the river-flood raster is. Measured 2026-08-19: a Piraeus waterfront pin at 37.94, 23.64 comes back available: true but no_bandable_reading — the dataset answered, the pixel is dry. That is the documented sparsity, not a defect: the same trap as the step 2 note above, where most Greek coordinates return nulls from both river-flood datasets. Pick a coordinate that is actually inundated in the coastal COG you selected before you walk this leg, or you will demo a blank card. The provenance chip must still be present and correct on a no_bandable_reading card — that, not a number, is what step 5 is asserting.

Band values recorded before 2026-08-16 are not comparable. Migration 0086 (#925) recalibrated the heatwave band edges, #942 moved earthquake onto the RP-476 design return period, and ADR-074 (#935) made an absent heatwave cell read as zero. Two prod readings measured 2026-08-19 you can anchor on: Athens 37.9838, 23.7275 earthquake → band score 6 "Elevated", band_return_period 476.0, band_value 0.2477 g; and the Attica admin-unit rollup (boundary e7f12fe5-4c95-4058-9560-14df355f53f0) → river_flood band 2 "Very low" at 0.0657 m over 28 cells from 2 surfaces, earthquake headline 6. No heatwave band was read on v0.15.1+ — measure that one on the day rather than expecting a label.

The heatwave leg runs on the code default budget — no env override needed. #885 and

901 are both closed (2026-08-14): the original ~4 s heatwave COG point read was 100x read

amplification from pixel-interleaved deep-stack COGs, not an underpowered timeout. #901 rebuilt them band-interleaved, and reads now measure 27.7–568.2 ms, comfortably inside the code default budget. RISK_LOOKUP_COG_TIMEOUT_SECONDS should not be set on the api container — its absence is the healthy state. If heatwave misses everywhere with cog_read_failed, that points at a genuine regression, not a missing override — file it.

Verdict: PASS if all 21 datasets tile and every lookup answer is attributed.


Step 6 — One pipeline, no side doors

Not a UI step — it is an audit. Do it against the object store and the catalog.

Do: for each of the 21 active datasets, confirm a source manifest exists at hazards/{dataset_id}/source_manifest.json (ADR-066), or at the legacy staging/{ingest_job_id}/manifest.json for the pre-ADR-066 rows. scripts/backfill_source_manifests.py relocates the legacy ones onto the dataset-scoped key verbatim — real captured checksums survive. Read the capture_mode flag: a "reconstructed" manifest was assembled after the fact and leaves checksum_sha256/volume_bytes null.

Expect: every dataset traces to a re-runnable pack. No raw/ copy exists for any of them, and none should — ADR-070 makes the raw zone transient by design.

About the demo fixture, explicitly. JRC River Flood Greece (demo) is written by scripts/seed_demo.py and it is in the catalog. Under #631's rule that would read as a FAIL, so record it precisely: it is present and visible, but demoted out of every resolution path — models/hazard.py::eligible_candidates removes seeded fixtures from any group that also holds catalog data, in both the point lookup (#810) and the scenario matrix (#631). The FAIL condition #631 named is a seeded row answering a real query, and that cannot happen while real river-flood data exists. Verify rather than assume: confirm the step-2 river-flood lookup on "Auto (recommended)" resolves a real dataset, and that the step-4 batch's nine cells all resolved Aqueduct ids.

Verdict: PASS if every dataset traces to a manifest and nothing seeded is answering.


After it passes

  1. Tick the phase-exit box in phase-9-build-plan.md.
  2. Flip the Current Phase banner and the Phase status row in plan.md.
  3. Only then may the Full-Catalog claim be made to an audience — and the standing licensing rule still holds: nothing publishes publicly before the licensing verdict.

If a step fails

Capture the failing job id, the request_id from the API response, and ./dc logs --tail=200 worker on the host. Open an issue against the phase and link it here. Do not waive a step to close the phase — Phase 8 exited once on a waived walkthrough criterion and carried four defects forward into Phase 9; that is the cost this file exists to avoid repeating.