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.

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.14.1 / 0704467… or later (measured 2026-08-12)
That SHA really contains a given fix git merge-base --is-ancestor <fix-sha> <deployed-sha> exit 0
UI tag docker ps on the host ghcr.io/cortomaltese3/climate-lama-ui:v0.5.0 or later (the tag carrying ui#100/#101)
Alembic at head ./dc exec -T api alembic current matches alembic heads on that tag
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
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; 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, then VERIFIED 9/9 on v0.14.0 The old "≥2 scenario-conditioned computable hazards" could not be met: only river_flood qualifies, and no second hazard can qualify without new science. Step 4 now runs river flood's full 3 × 3 grid — see Step 4 was amended for the rationale and the reinstatement condition, and the table below for why each other peril fails. #836 closed on that decision; #833 stays open as the real fix. Do not re-propose earthquake or wildfire — both are ruled out by physics, not by backlog. Then rehearsed for real — see Step 4 rehearsal below: it scored 6/9 before #861 and 9/9 after.

Why no second hazard qualifies (prod, re-verified 2026-08-11):

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 0 data ✓, curve ✗ — #833, and Huizinga reuse is explicitly forbidden there (fluvial calibration ≠ surge/wave)
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, #412) 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.0v0.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.

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=200status=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 one hazardamended 2026-08-11, see below Run the matrix over river flood's complete grid: baseline / ssp2-4.5 / ssp5-8.5 × 2030 / 2050 / 2080 All 9 cells return values; 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 — the baseline row repeats across the three horizons by design. 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.

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. Heatwave and drought have no published building-damage curves — they are hazard-indicator perils: ingested, tiled, scored, shown on the score card, and excluded from EAI until a defensible curve exists. 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.

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 you will see. Record them; do not chase them mid-sitting.

  • The provenance chip doubles the "v"vv3.1.1, vv2, vESHM20 — because most rows' upstream_version already carries the provider's own v. climate-lama-ui#116 is fixed on UI main (PR #117, merged) but prod runs v0.5.0, which predates it. Cut a UI v0.5.1 before the sitting if you want it clean.
  • heatwave and earthquake render as raw enum strings everywhere in the UI. Neither has an entry in the lookup or glossary label maps; the other four types do. Not a step failure — the metadata is present, only the display name is missing — but it is the most visible presentability gap in the whole walk.

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: /datasetsHazards 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 rowsheatwave-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.

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 three times:

# 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

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 cannot be run at all — 7 datasets, 0 impact functions (#833). The Impact-function select stays empty and Run is refused with "No impact functions for coastal_flood". That is the honest state, not a defect.

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.

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.

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 one hazard

Page: /matrix.

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: PASS if 9/9 completed and all nine resolved inside the Aqueduct family.


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.

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 pathmodels/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.