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:
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''::uuidraises. 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.
GET /v1/hazardsno longer defaults to every status. #873 / PR #876, shipped inv0.14.1, hidesstatus='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.- That 22nd row is the demo fixture, and it is visible.
JRC River Flood Greece (demo)(d67dbbb7-69af-4f4e-a775-89708199fc1f, sourceClimate-Lama reference fixture) carriesstatus = 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 blankprovider,upstream_version,native_resolutionandcitation, 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. - Step 4's stated check is not observable where the step implies.
provider_fallbackis computed incore/compute_batch_service.pyand 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-cellhazard_dataset_idis not on the batch response either:GET /v1/compute/impact/matrix/{id}cells carry onlyscenario_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}carrieshazard_dataset_id. See the run sheet's step 4. - Which dataset you seed the matrix with decides the outcome, and no step says so. The
base dataset's ADR-048
provideris what #591/#861 rank the per-cell family against. Every verified 9/9 batch seeded witha085c0b9-…— Aqueduct river flood Greece — historical baseline 1980 (WATCH), providerWorld 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. - "9/9 verified" was verified against the demo exposure. Both the
v0.14.0batch (547c2833…) and a fresh one run today onv0.14.1(f90d23f5-7ad0-4a70-be69-0c9616609eb8, 9/9completed, all nine inside the Aqueduct family, baseline resolving the WATCH 1980 row at all three horizons) usedexposure_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. - 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_SECONDSoverride 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=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 one hazard — amended 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:
- 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.
- 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 atbaseline— 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: nullthere 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 |
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_versionalready carries the provider's ownv.climate-lama-ui#116is fixed on UImain(PR #117, merged) but prod runsv0.5.0, which predates it. Cut a UIv0.5.1before the sitting if you want it clean. heatwaveandearthquakerender 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: /datasets → Hazards tab (the default).
Do:
- Leave every filter on "All …". Read the count in the top-right of the filter bar.
- Open the Hazard filter and enumerate its options.
- 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. - Cross-check the API — with
status=active:
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_flood7,earthquake1,heatwave3,river_flood8,storm_europe1,wildfire1 — and two distinctsourcevalues forriver_flood(JRC v3.1.1 · WRI Aqueduct v2) and forheatwave(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:
- 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. - 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_idat all. - Pick
JRC river flood Greece (national, historical baseline)— id67854ef5-8052-435d-9828-1a1015146654. Read the values table and the provenance chip. - Switch the same select to
Aqueduct river flood Greece — historical baseline 1980 (WATCH)— ida085c0b9-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:
- Hazard dataset:
Aqueduct river flood Greece — historical baseline 1980 (WATCH)(a085c0b9-09fc-40fd-aa80-6fa0f9451ac4). This choice is load-bearing — the base dataset'sprovideris 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. - Impact function:
JRC flood Europe buildings(the default). - Exposure:
LitPop Greece demo— the exposure both verified 9/9 batches used. - Scenarios: tick Baseline, SSP2-4.5, SSP5-8.5 (SSP2-4.5 is pre-ticked; untick nothing else).
- Horizons: tick 2030, 2050, 2080 (2050 is pre-ticked).
- 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 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¶
- Tick the phase-exit box in
phase-9-build-plan.md. - Flip the Current Phase banner and the Phase status row in
plan.md. - 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.