Phase 17b · The run model¶
Status: defined 2026-10-02, the reference for every EX, AD and AS issue of Phase 17b
(phase-17b-walk-corrections.md, guardrail 11). This document
answers the owner's question of 2026-10-02: what is a run, which inputs does it take, in which
order, resolved from where, and what business value each step carries. It fixes the business logic,
the backbone contract and the Explore dock in one place so that /phase-split cuts issues that
agents can build without inventing semantics. It was verified by a conceptual /reassess refuter
against the engine specification, CLIMADA's framework, the Oasis LMF evaluation on disk and the
market scan (see Reassess record). Nothing here is implemented yet.
Amended 2026-10-05 from the fine-tune record (phase-17b-finetune.md).
The owner decided 17 design points on the rebuilt prototype (FD-1 to FD-17, decisions K36 to K52 of
the phase doc), and nine contract changes follow (BC-1 to BC-9). This document states the decided
design as the current truth: Scope through two doors, a dock of two numbered steps with Layers on
the map, a clip that may list several units, the damage curve as a select, measures as chips, one
caption over the map, and a run that keeps going outside Explore. The design target is the
prototype snapshot in the UI repo, docs/prototype/v2/ at version 1791210282-291b. Its layouts,
states, copy and behaviour are the target. Its figures are illustrative and never ship (ADR-077).
In UI copy the user's own assets are a "portfolio" and a reference dataset is an "exposure layer"
(K49), and the saved-work list is named History (K43, the route stays /analyses). Where older
prose in this document says "book" it means a portfolio. The sections that changed are listed in
the Reassess record.
Sources read for this document: climate-lama-engine/docs/CLIMATE_LAMA_ENGINE.md (§1 risk
equation, §2 marginal versus occurrence frequency, §6 measures, §7 cost-benefit),
climate-lama-engine/docs/CLIMADA.md (§3 IPCC framework, §6.6 to 6.8, §7.2), OasisEvaluation
(README inputs, OasisPiWind/analysis_settings.json), docs/plan/exploration/01-market-scan.md,
scripts/market_scan/data.js, docs/plan/showcase-script.md, the backbone compute, lookup and
assistant code at 02d3f092, and the owner's earlier tool's ScenarioRunRequest
(risk-assessment-tool/backend/models/scenario.py: annualGrowth, exposure, hazard, scenario,
timeHorizon, measures, impact-function override).
1. The question¶
On the 2026-10-02 walk the owner could not read Explore's Scope, Layers and Analysis tabs as a flow. Is the scope the exposure? Is a portfolio the uploaded CSV of the earlier tools? What is a multi-country scope when no exposure has been chosen? What does Layers select, and what is on the map? Where are the adaptation measures, the growth of the exposure, the impact function? Is "Events" a static list? The owner asked for a conceptually and scientifically correct flow that any scientist can follow, not a copy of CLIMADA, and one that carries business value.
The short answer: the product never wrote down what a run is. Each dock component is correct alone and the backbone contract takes one exposure selector and a hazard cell, while measures and growth exist only in cost-benefit. This document is the missing definition.
2. The risk equation in two stages¶
Risk is hazard × exposure × vulnerability (IPCC, CLIMADA.md §3, engine §1). Every catastrophe model and every pace-setter in the market scan computes it in the same order, and that order is not a CLIMADA artefact. Two stages are kept apart because different tools and different users own them.
Stage A, the loss run (catastrophe-model standard, Oasis-shaped). An exposed set (a book or a
sub-book) → a hazard type → an event set or a scenario × horizon cell → a vulnerability curve per
exposure type → ground-up loss per location → financial terms (deductible, cover) → expected annual
impact, a loss-exceedance curve at a labelled basis. Oasis LMF takes exactly this shape: OED
locations first, then the model and its event set per analysis, then ground-up → insured → reinsured
outputs (OasisEvaluation/README.md:86-95, OasisPiWind/analysis_settings.json:7-61). Moody's
runs "portfolio, then analysis (portfolio, model, settings), then a run" (data.js:202). Climate X
offers address, CSV or polygon as three entries into one analysis, all of which define the exposed
set (data.js:249).
Stage B, the appraisal (economics of climate adaptation, CLIMADA-shaped). The same run under a
future state: exposure growth, then adaptation measures that transform the hazard, the damage curve
or the protection standard, then avoided loss and a benefit-cost ratio over a horizon with a
discount rate. This is CLIMADA's Entity plus CostBenefit (CLIMADA.md §6.8, §7.2) and the engine's
§6 to §7. The pace-setters mirror it as a toggle or ranked options (Climate X "Toggle Flood
Defenses" data.js:250, Jupiter "Automated Resilience" data.js:301, FloodAdapt data.js:794).
Oasis has no Stage B. The product must never say it does.
Two surfaces are deliberately outside the run model:
- Lookup is a place-first hazard screening. It scores a point from hazard intensity alone and never runs a loss (§8). The pace-setters' address journeys (First Street, ThinkHazard) are the same shape.
- Events (Explore → Events) computes the loss of an observed footprint on a real clock (ADR-078, ADR-081). It yields an event loss, never an annual figure, so showcase Moment 2 steps 8 to 9 and Moment 4 steps 1 to 8 pass through Events mode, not through the run form.
3. The inputs, in the order the form asks for them¶
The order is the mental model of a scientist setting up a run: what is at risk, where, under which
hazard, in which future, how it is damaged, how it will have grown, what is done about it, with
which engine. Vulnerability comes before measures because a measure transforms the damage curve
and the hazard, so both operands must exist first (engine §6, Measure.apply(hazard, impfset)).
Growth sits between them because a measure's benefit is appraised against the grown,
climate-conditioned baseline.
Scope asks for the first two inputs together, through one question and two doors (§3.1, K38). Under the door "A place" the country comes first and its exposure layer fills in by itself, so nobody needs to know a dataset's name to run a country. The inputs of a run and their logical order stay as listed here, and the clip stays a filter on the exposure.
3.1 Exposure, what is at risk¶
Scope asks one question, "What do you want to assess?", and answers it through two doors (K38, EX-18). Both doors resolve to the same exposure selector, so the request contract for exposure is unchanged (§6, BC-2).
A place opens first. Country chips come from GET /v1/catalog/coverage, never from a constant,
and several may be on. The exposure layer fills in by itself, one line per country, which is how
"multi-country" is expressed: a selection of catalogue exposure datasets in several countries,
all of one value unit (LitPop Greece, LitPop Cyprus, GHSL built volume, census persons). The
default layer of a country follows one rule: a money unit first, then exposure type buildings, then
the newest. The rule lives once, in the backbone (§6 "Default exposure layer"). No new route serves
this door (BC-2): the UI lists GET /v1/exposure-datasets?country=<ISO3>
(api/v1/exposure_datasets.py:80-107), reads the envelope key default_dataset_id and computes
nothing. "Change" opens the layers of that country inside the card as radio rows, the default
one tagged "default" and a non-money one tagged "score only", and the last row is "Upload your own
exposure". A country with no layer reads "No exposure layer for
My assets offers three kinds. A portfolio, the user's own assets uploaded as CSV, addresses
or pins, with membership through portfolio_exposures. Pins, which are saved as a one-member
or n-member portfolio through POST /v1/exposures (D17). One asset, the n = 1 case. Below them
sit "Upload a portfolio" and "Narrow to a place (optional)" (§3.2).
The head of Scope shows the resolved line, for example "Greece · Attica · LitPop Greece (USD) ·
12,480 locations" (an example of the format, its figures are illustrative). The exposure_type
column (buildings, population, infrastructure) and the value_unit column decide what can be
computed (§3.5). Business value: a country run, a region run and an insurer's portfolio run start
from the same question, and the scientist's reference exposure, the insurer's portfolio and the
citizen's pins are one concept, so every later step and every result reads the same way.
3.2 Where, an optional clip¶
A country, one or more regions (admin level 1), one or more municipalities (admin level 2) or a drawn area narrows the exposure to the rows inside it. It is a filter on §3.1, never an input of its own: a region without an exposure has nothing to compute.
Under the door "A place" the choices are Whole country, Region, Municipality and Draw area, and they exclude each other (K50, EX-23). Choosing one clears what another one set: a drawn area leaves the map together with its clip, the other level starts empty, and changing the country chips clears every narrowing. Narrowing is offered only while exactly one country is on. With two or more on the line reads "Pick one country to narrow to a region or municipality."
A chosen region or municipality shows as a chip with a remove button, and "Clear all" appears from two. Several units can be chosen, in the dock (the Region list ticks several, the Municipality search adds several) or by clicking the map, where a click on a unit of the level being picked adds it and a click on a chosen unit removes it. One rule holds and the dock states it: "Regions or municipalities of one country, one level at a time." The clipped set is the union of the chosen units, each row counted once (BC-1, §6). Under "My assets" the same control narrows the portfolio, with the choices No clip, Country, Region, Municipality and Draw area.
The result's scope_total counts the clipped members, and the result's metadata records the clip
so that every path that re-joins values to locations reads the same subset (§6). The map frames the
clip, outlines every chosen unit and draws the exposure inside it before any compute.
3.3 Hazard¶
A hazard type. With one country in the exposure set it is offered only where
GET /v1/catalog/coverage reports it computable for that country (D14). With several countries
coverage counts cannot decide, and the picker reads the per-hazard state of §6 instead (see
"Several countries" below). The dataset is resolved by the backbone from the hazard type, the
exposure set's countries and the cell of §3.4 through the same rule the matrix uses (ADR-048
provider family, ADR-069 frequency-less datasets excluded). The UI never sends a free-text region
and never picks a dataset id. The run form has no control that pins one, and an API caller can
still send hazard_dataset_id. Countries come from the shared
derivation of #1166 (largest_overlap_admin0_iso3 over the exposure's bbox, the rule coverage
already uses), so a LitPop Greece plus LitPop Cyprus run has the country set {GRC, CYP}, and a
hazard dataset answers it only when its footprint covers every exposure bbox. One run never
stitches per-country hazard datasets. Choosing the hazard turns its surface on at the design return
period, so the user sees the field over the exposure in Run before running. The switch for it sits
in the Layers popover of the map (§7), and with Scope open the map shows exposure only.
The picker is one row (icon, name, status, chevron) that opens a list in three groups, "Computable", "Score only" and "Not available here", with the reason beside each hazard that cannot be picked (K39). A row of hazard icons is not built.
Several countries (K41, EX-19, BC-3). For a set of countries every hazard type is in one of
three states, by the hazard resolution of §6. The states are one enum, state: covered (one
dataset covers all of them), no_single_dataset (datasets exist for the set and none covers all
of it) and not_covered (no dataset of that hazard lies in the set). With two or more countries
on, the door "A place" lists them under "Hazards for covered, "No single dataset covers both" for
no_single_dataset and "Not covered" for not_covered (from three countries "all 3" stands in for
"both"). In Run a hazard in the state no_single_dataset is off with its sentence, "No single
not_covered is off with its reason. A per-country batch is not built (§13). Whenever more than
one country is on, the list in Scope and the picker in Run read that state from the read route of
§6 and never from coverage counts. Coverage counts cannot tell one dataset that covers both
countries from one dataset per country, so a picker that enabled a hazard because every country's
computable_count is above zero would offer a run the backbone refuses. The three states come from
one rule with one implementation, shared with §6, so the list in Scope, the picker in Run, the
assistant's list_hazard_options and the compute refusal cannot disagree.
3.4 Scenario × horizon¶
Climate conditioning of the hazard: Today (baseline) plus SSP × year cells that coverage says are
computable, multi-selectable into one matrix run (EX-3). A hazard is not climate-driven when
coverage lists no dataset with a non-baseline scenario for it in the exposure's countries, and
GET /v1/catalog/coverage exposes that as climate_driven: false per hazard type. For such a
hazard (earthquake) the SSP axis has no effect: a cell (s, y) with s ≠ baseline resolves to the
baseline group (today the resolver looks candidates up by the cell's scenario label and would
miss, compute_batch_service.py:363), the result records
provenance.hazard_conditioning = "not_climate_driven", and the dock reads "hazard: same as
today, not climate-driven, exposure grown g %/yr". It is neither n/a nor a projection. Business
value: the scientist's grid and the insurer's "what about 2050" share one addressing scheme, and
the saved Scenario replays it.
3.5 Vulnerability¶
The damage curve is resolved from (haz_type, exposure_type): for a money-valued exposure of type
buildings, the hazard's default built-in curve (JRC for floods, ESRM20 for earthquake, and so on,
core/impact_function_seeder.py). It is shown as a visible select labelled "Damage curve" (K47).
The resolved default comes first and reads "Default · population, infrastructure)
answers 422 "no vulnerability curve for exposure type population", and a non-money value_unit
(m3, persons, the units PF-2 lists as non-money) answers 422 "no money loss for value unit m3".
In both cases the UI reads "score-only exposure: no money loss can be computed" and shows no
select. Per-asset binding
by occupancy or construction is Phase 18 (D21). The limit is stated in the result's provenance.
3.6 Future exposure growth¶
An annual rate g (default 0). The worker scales each exposure value, and its deductible and cover,
by (1 + g)^(horizon_year − reference_year) before the engine, where reference_year is the row's
own dataset's recorded value epoch (properties.reference_year, which the LitPop pack writes as
2018, else temporal_coverage_end, else the run year, and the UI says which). A union of datasets
or a portfolio spanning datasets compounds each row from its own dataset's year. This is CLIMADA's future
Entity and the owner's earlier annualGrowth, paired with the discount rate as in Stern (2007) and
Nordhaus (2017). It scales existing locations only: no new locations enter hazard zones, and the
dock says so. Growth is counted exactly once (§6, §9).
3.7 Adaptation measures¶
Zero or more measures from the library, filtered to the hazard. A measure is an engine transform
(engine §6): a hazard-intensity factor, a damage-curve factor, or a protection standard expressed
as a return period and mapped onto the dataset's return-period bands (AD-2), never as a raw
frequency. The run form shows only the chosen measures, as chips (K40): each chip carries the
measure's name, a small before/after sketch of the resolved curve or hazard and a remove button,
and with none chosen the form reads "No measures in this run". "Add measures" opens a searchable
picker over the map that lists only the measures of the run's hazard, and "Use a saved plan" loads
the measures of a saved plan of that hazard
(domain model § 4.3). No shortlist pinned
in Adapt is built (§13). Measures
ride inside the run (measure_ids, AD-1) and produce an adapted result with its own tiles and
per-location EAI. Appraisal in money (avoided loss, BCR, payback) is §9.
3.8 Engine¶
climate_lama (default), climada, oasis, from GET /v1/engines, each disabled with its reason
when it lacks the hazard. "Compare engines" fans the same inputs across the live engines through
/compute/impact/compare, which gains the same fields as /impact and the cell addressing EX-11
asks for, so a future, clipped, grown or adapted run can be compared too.
4. Outputs¶
- Per-hazard expected annual impact (
ead,aai) for the exposed set, clipped or not. There is no multi-hazard aggregator in the engine (§10), so a multi-hazard figure is a sum the UI labels as such or does not show. - A loss-exceedance curve with its basis labelled (
curve_basis, #1476). On return-period hazards the basis is "return-period-map: the whole footprint at the RP-N intensity at once, fully correlated, an upper bound for a dispersed portfolio, no stochastic event set". PML is served only at return periods inside the dataset's own set[RP_min, RP_max], and the strip's return-period control offers only those. - Per-location EAI as tiles (D4) and
/per-exposure,scope_totalandrows_computed. The result's stored admin units, ranked by EAI, throughGET /v1/results/{id}/admin-cells(SH-16, #1636): Home's top municipalities and the run drawer's treemap read them without a request-time admin join. - A result checksum (SH-10, #1636).
GET /v1/results/{id}serveschecksum, the SHA-256 of the result's storedeai_exp,impact_per_eventandfreq_curvein the canonical byte form ofdocs/CLIMATE_LAMA.md("Result checksum"), stamped by the worker. The same inputs on the same engine version give the same checksum. It is null on a result written before it. - Score bands where
score_bands_enabled(D6). - The result's design return period and average band (EX-10, #1595).
GET /v1/results/{id}also servesdesign_return_period, the return period the compute banded at (100 for river flood and 476 for earthquake under the default settings), andvalue_weighted_average_band, Σ band ×exposed_valueover Σexposed_valueof the result'sband_distribution, computed by the function behind the rollup'svalue_weighted.combined_score. Each is null on a result written before the change, and the average band is null whereverband_distributionis null. - Stored results of given run inputs (EX-10, #1664).
GET /v1/results?exposure_dataset_ids=…&cells=…lists, per hazard type and cell, one row{result_id, haz_type, scenario_label, horizon_year, ead, value_unit, value_weighted_average_band, indicator_score, created_at}: the newest stored result over exactly the inputs of a run. It never queues a job and has no total row (§6, "Stored results of given run inputs"). An indicator hazard has no stored result, so its row carriesindicator_score, the value-weighted typical score of the selector's rows read from the indicator dataset by the sampling function the portfolio rollup uses (#1665, domain model § 3). - Provenance on every result: engine and version, hazard and exposure hashes, impact function and version, scope, growth (rate, reference year, horizon year), measure ids, scenario label and horizon.
- Save as a Scenario (
inputsalready typed formeasure_ids,discount_rate,growth_rate), listed in History (K43). A saved run carries every run key, its batch id and its completed cells (§6 "Saved work", BC-5), and reopening it restores the whole run (§7, EX-21). Thescenario_idreplay of #1186 covers one cell, so a re-run goes through the run form. - Report through the Reports studio widgets.
5. Today versus target¶
| Input | Today (backbone 02d3f092, UI b4578fc) |
Target |
|---|---|---|
| Exposure | One exposure_dataset_id or one portfolio_id (compute.py:290-291), chosen in the Analysis tab as component state (AnalysisDock.tsx:89,212-240) |
exposure_dataset_ids list of one unit, or portfolio_id, chosen in Scope through two doors: "A place" fills in the default exposure layer of each country, "My assets" picks a portfolio, pins or one asset. In the URL |
| Where | Country, region, municipality, area frame the map only, never sent (analysis.ts:155-206) |
scope clip applied in the compute order SQL and persisted on the result: one country, several regions or several municipalities of one country, or a drawn area |
| Hazard | Grid in the Analysis tab, dataset resolved by cell (#1185) | One row that opens a grouped list, gated by coverage for the exposure's countries. With several countries a hazard with no single dataset is off with its reason. Surface on in Run, its switch in the Layers popover |
| Scenario × horizon | Single select, batch only through "Compute every cell" (EX-3) | Multi-select cells, one matrix POST, non-climate rule. A tick puts a cell in the run, a click shows it |
| Vulnerability | Hazard type's single default, exposure type ignored (impact_function_service.py:175-185), a select in the dock |
Resolved on (haz_type, exposure_type), a visible select with the resolved default first and a curve preview, 422 for non-money |
| Growth | Cost-benefit only (value_growth_rate) |
exposure_growth_rate on impact, matrix, compare, applied once before the insurance transform |
| Measures | Cost-benefit only | measure_ids on impact, matrix, compare (AD-1), adapted results. Picked as chips through "Add measures" or "Use a saved plan" |
| Engine | X-Engine header, compare on Today only |
Unchanged header, compare accepts a cell and the new fields |
| Map before compute | Nothing drawn for a portfolio, no fit (EX-14) | With Scope open, exposure only: the scope assets layer from tiles, fitted, with the exposed-value legend. In Run the hazard surface joins it |
| Map display | A Layers tab that lists every dataset switched off (useHazardLayers.ts:20-26, EX-16) |
A Layers button in the map's control group opens a popover. No input to the run lives there |
6. The backbone contract¶
One ADR, "The run contract: exposure set, clip, growth and measures on impact compute",
extending #1008 (exposure selector) and #1185 (scenario × horizon), superseding in part ADR-019's unimplemented
"job submission body gains measure_ids" clause. ADR-086 is untouched.
Request fields on POST /v1/compute/impact, POST /v1/compute/impact/matrix and
POST /v1/compute/impact/compare:
| Field | Type | Rule |
|---|---|---|
exposure_dataset_ids |
list[UUID], optional |
Union of the named datasets. All must share one value_unit and one exposure_type, else 422 naming the mismatch. Exclusive with portfolio_id and with the legacy exposure_dataset_id, which stays as a one-element alias |
portfolio_id |
UUID, optional |
Unchanged. Added to /compare, which lacks it today |
scope |
{"admin_boundary_ids": [UUID, ...]} or {"admin_boundary_id": UUID} or {"bbox": [w, s, e, n]} or null |
Exactly one of the three keys (BC-1, K50). admin_boundary_ids lists one or more units of one admin level and one country, and the clipped set is their union, each row counted once. admin_boundary_id stays as a one-element alias of the list. One extra predicate inside compute_order_sql(columns, scope=…) so the engine's eai_exp order and scope_total both see the clipped set. Persisted as result.metadata.scope, a boundary clip always in the list form with its ids sorted, so the alias and the list never differ downstream. Every consumer that re-runs the join reads it from the result: GET /v1/results/{id}/per-exposure, /tiles/results/{id}/… (assets tier), the surface writer. A clip that leaves no rows is the existing "nothing to compute" refusal |
exposure_growth_rate |
float, default 0, −0.5 ≤ g ≤ 0.5 |
Applied by the worker to value, deductible and cover of every row as (1+g)^(horizon_year − reference_year) before the engine. reference_year per dataset = properties.reference_year if present, else temporal_coverage_end, else the run year. A Today cell has exponent 0. Recorded as provenance.exposure_growth = {rate, horizon_year, reference_years: {<dataset_id>: {year, source}}}. The dock's hint reads the served field reference_year = {year, source} on GET /v1/exposure-datasets rows and GET /v1/exposure-datasets/{id}, from the one resolver the worker's growth step calls (core.exposure_growth.resolve_reference_years, #1593); a dataset with neither epoch serves {year: null, source: "run_year"} |
adapted_from_result_id |
UUID, optional |
AD-1's pointer to the unadapted result an adapted run was derived from. Recorded in provenance and on the saved Scenario, never read by the worker. Must belong to the caller's org, else 404 |
measure_ids |
list[UUID], default empty |
AD-1's field. The worker applies Measure.apply before ImpactCalc. A measure whose hazard differs from the resolved dataset's is 422 (AD-5). A protection standard removes the event rows with rp <= RP_protect before the engine (AD-2, as amended) |
scenario, horizon_year, haz_type, region |
as #1185 | Added to /compare so an engine comparison can address a future cell (EX-11) |
cells on /matrix |
list[{scenario_label: str, horizon_year: int or null}], optional, 1 ≤ len ≤ 24 |
Exclusive with scenario_labels × horizon_years, which keep their Cartesian meaning (both sent → 422 "send cells or scenario_labels × horizon_years, not both"). With cells, or with haz_type (plus optional region), hazard_dataset_id becomes optional on /matrix exactly as on /impact (#1185). Today is {scenario_label: "baseline", horizon_year: null}, the URL's baseline@today. A non-rectangular pick such as baseline@today, ssp2-4.5@2050 is two cells, never four |
hazard resolution without region |
rule | When region is omitted and an exposure selector is present, candidates of haz_type are the datasets whose derived country (largest_overlap_admin0_iso3 over COALESCE(footprint, bbox), #1166) lies in the exposure set's derived country set, and whose footprint covers every exposure bbox. None → 422 "no single hazard dataset covers GRC, CYP; narrow the exposure or the clip". An explicit region keeps #1185's exact match. Without an exposure selector #1185 is unchanged |
| non-climate cells | rule | A hazard is not climate-driven when coverage lists no dataset with a non-baseline scenario for (haz_type, countries); coverage exposes climate_driven: bool per hazard type. A cell (s, y) with s ≠ baseline for such a hazard resolves to the baseline group in resolve_cells_within_scope and records provenance.hazard_conditioning = "not_climate_driven" |
Vulnerability resolution (core/impact_function_service.py): when impact_function_id is
omitted, resolve on (haz_type, exposure_type). Money unit (PF-2's list) and type buildings → the
hazard's is_default curve as today. An exposure type without a curve → 422 E_VALIDATION with
details.reason = "no vulnerability curve for exposure type <type>" and details.exposure_type.
A non-money unit → 422 with details.reason = "no money loss for value unit <unit>" and
details.value_unit. A caller-supplied impact_function_id still wins over the first refusal,
never over the second.
Default exposure layer (BC-2, EX-18). The default exposure layer of a country is decided once,
in the backbone. One core function, default_exposure_dataset(repo, org_id, country_iso3),
applies the rule of §3.1 in this order: a money unit first (PF-2's list), then exposure type
buildings, then the newest created_at. It reads every dataset of the organisation that is not
superseded and whose derived country is that ISO3, whatever search, exposure_type, limit and
offset the list request carries. GET /v1/exposure-datasets?country=<ISO3> serves its result as
the envelope key default_dataset_id beside total. The key is null when the request sends no
country or when no dataset qualifies. It is an envelope key and not a flag on each row, because
the list is paged and filtered and the default may sit on another page. The UI reads the key and
computes nothing. find_exposure_datasets (§10) calls the same function and marks the same row.
The SDK's exposure dataset list (sdk/python/climate_lama_client/client.py:450, aio.py:475)
gains the optional key in the same PR.
A dataset with no bbox has no derived country (api/v1/exposure_datasets.py:116-121), so it
never lists under a country and is never a default. Code check of 2026-10-05: the upload path of
POST /v1/exposures writes the dataset row with no bbox, for a CSV, an XLSX and an HDF5 file
alike (core/exposure_service.py:1045-1060, called at api/v1/exposures.py:327). The streamed
source ingest does record the extent its adapter measured (core/exposure_service.py:1193-1215,
core/ingest/exposure.py:182). Nothing fills the column in later. "Upload your own exposure"
therefore creates a layer today that the door "A place" can never show. The rule: the upload path
records bbox as the envelope of the uploaded points, in the backbone box of EX-18 that adds the
default function. An upload made before that change still has no bbox, so the same box fills
it in for the datasets that already exist, from their stored points, and the release that ships
it runs that fill on prod.
Hazard options for an exposure selector (BC-3, EX-19). Before Run the UI must know, for an
exposure selector and an optional scope, what each hazard type can do: one dataset covers every
country of the set, no single dataset covers them, or the hazard is not covered (§3.3). That answer
is the hazard resolution of the table above and nothing else. EX-15 lands it as one core function
over an exposure selector, an optional clip and haz_type, which /impact, /impact/matrix and
/impact/compare call. Code check of 2026-10-05: list_hazard_options does not exist yet
(grep -rn list_hazard_options src/ finds nothing), and the plan builds it as an assistant query
tool. Query tools run server-side inside POST /v1/assistant/messages, the only assistant route
(api/v1/assistant.py:72, schemas/assistant.py:48), so the UI cannot call one. Coverage cannot
stand in either: GET /v1/catalog/coverage serves counts per country and hazard type
(dataset_count, computable_count, indicator, computability,
core/catalog_coverage.py:155-166) and names no dataset, so two countries that hold one dataset
each read the same as two countries that one dataset covers. The rule is therefore assistant-only
as planned, and the UI needs a read route over the same core function. It takes the exposure
selector (portfolio_id or exposure_dataset_ids) and an optional scope. It returns per hazard
type haz_type, climate_driven, the computable cells, a state and a reason.
state is one enum of three values. covered: at least one candidate dataset of the hazard
resolution above covers every exposure extent, so a run resolves. no_single_dataset: candidates
exist for the set and none covers every exposure extent. not_covered: the hazard has no
candidate for the set. reason is null for covered. For the other two it is the text the
compute refusal of this section gives for that hazard, so the picker and a refused run say the
same thing. A hazard that is not covered lists no cells.
list_hazard_options returns the same state, reason, money and money_reason per hazard type. For a set of several
countries it answers with those states and does not refuse the whole call, because the place
sequence of §10 calls the tool on a place and one hazard without a covering dataset must not hide
the hazards that have one. The tool still refuses, as a tool error, a mixed-unit set and a
selector the caller's organisation does not own. Whenever more than one country is on, the hazard
picker reads state from the read route and never coverage counts (§3.3). One rule, one
implementation: the read route, the assistant tool and the compute refusal call that one function.
The route is GET /v1/compute/hazard-options (#1661, decided 2026-10-07, K55). It takes the
selector as portfolio_id or exposure_dataset_ids (repeated) and the optional clip as
admin_boundary_ids (repeated, one level and one country) or bbox=w,s,e,n, and answers
{scope_total, hazards: [{haz_type, climate_driven, state, reason, money, money_reason, cells: [{scenario_label,
horizon_year, hazard_dataset_id}]}]}. scope_total is the number of locations of the selector
inside the clip, or of the whole selector without one, counted by the backbone with the clip rule
compute uses, so the head of Scope can name it before any run (EX-18, EX-23). money is true when the hazard is covered, is not an indicator hazard, and the vulnerability resolution /impact uses raises no refusal for the selector's exposure type and value unit. money_reason is null when money is true, the refusal's details.reason verbatim when it raises one ("no money loss for value unit m3"), and "indicator: a score, no money" for an indicator hazard, so the assistant stops before compute on a score-only portfolio, as the snapshot's does (#1673 item 17).
Refusals, every one a 422 E_VALIDATION whose details.reason is the text below:
- mixed exposure set → "exposure datasets differ in value_unit:
, " or "exposure datasets differ in exposure_type: , ". exposure_dataset_idsempty, or sent withportfolio_idorexposure_dataset_id→ "name one exposure selector".scope→ "unknown admin boundary", or "admin boundaries differ in admin_level: , " (mixed levels), or "admin boundaries differ in country: , " (mixed countries), or "bbox must be [w, s, e, n] with w < e and s < n inside ±180 and ±90". The level and the country of a unit are the admin_levelandiso3thatGET /v1/admin-boundariesserves (api/v1/admin_boundaries.py:55-57).exposure_growth_rate→ "exposure_growth_rate must lie in [-0.5, 0.5]".measure_ids→ "unknown measure", or "measure is for , the run is " (AD-5). cellswith the Cartesian pair → "send cells or scenario_labels × horizon_years, not both".- hazard resolution → "no single hazard dataset covers
; narrow the exposure or the clip", or #1185's reason with its candidates. - vulnerability → the two texts above.
- cost-benefit → "appraise from an unadapted, ungrown present run".
- measure cost → "measure cost unit
differs from the exposure unit " (domain model § 4.1).
Worker order (worker/tasks.py, one numbered sequence): (1) load the exposure set named by the
selector, (2) apply the scope clip inside compute_order_sql, for a list the union of its units,
(3) scale value, deductible and
cover by the growth factor per dataset, (4) resolve vulnerability, (5) Measure.apply(hazard,
impfset) for each measure in order, (6) ImpactCalc ground-up, (7) the insurance transform
(deductible, cover), (8) persist the result with its provenance and metadata.scope.
Cache key (core/result_cache_service.py): append sorted(exposure_dataset_ids), scope,
exposure_growth_rate and the measure digest only when set, following the engine_name
pattern, so every existing key stays valid. scope enters the key in its stored form, a boundary
clip as the sorted list of ids, so one clip never mints two keys and the one-element alias shares
the key of the one-element list. The measure digest (amended 2026-10-09, #1594) is each sorted
measure id with the SHA-256 of the canonical JSON of the measure columns the transform reads,
built by the one helper the worker's transform reads its fields from
(core/measure_transform.py): PATCH /v1/measures/{id} mutates a measure and the table has no
version column, so an id alone would serve a stale adapted result. Patching a transform column is
a new key, renaming the measure is not. The old exclusion of measures from the key is deleted with
AD-1. Two defects the key has today are fixed first, in W0 (EX-17,
domain model § 1.4): deductible and cover join
the key (stringified, only when not null), so an insured and a ground-up run never share an entry,
and a membership digest joins it for portfolio_id and exposure_dataset_ids: the SHA-256 of
the sorted (exposure_dataset_id, dataset_sha256, selection kind, canonical selection JSON, weight)
tuples resolved at request time, so any attachment, selection, weight or re-ingest change mints a
new key.
Matrix cells reuse stored results (EX-20, #1662, decided 2026-10-07, K55). Each staged cell of
a matrix run carries the cache_key that compute_cache_key gives for the cell's resolved
dataset and the run's inputs, and the impact task looks that key up before it loads anything. On
a hit the job completes with the stored result_id and computes nothing. On a miss it computes
and records the entry, as the jobs of /impact do. The batch keeps one job per cell, so Stop, the
roll-up and the save still read jobs only. A cell's cached is derived and never stored: it is
true when the cell's result_id names a result that another job wrote.
Cost-benefit (POST /v1/compute/cost-benefit) over a base result: the base must be a Today
cell with measure_ids empty and exposure_growth absent or rate 0, else 422 "appraise from an
unadapted, ungrown present run". The engine applies value_growth_rate itself to the future stream,
so growth is counted once. The base result's scope is inherited: the cost-benefit worker loads the
same clipped exposure. Adapt's form defaults value_growth_rate to the Explore form's
exposure_growth_rate so the two surfaces agree.
Appraising a saved plan (#1641, AD-8, AD-13, AD-17): cost-benefit takes plan_id in place of
measure_ids — both is 422 "send plan_id or measure_ids, not both". The job then takes the plan's
measure_ids, its scenario × horizon_year as future_scenario × future_horizon_year, and its
discount_rate, time_horizon_years and value_growth_rate, so the body is impact_result_id and
plan_id alone (a sent cell or setting is a 422 naming it). The params record plan_id and the
plan's custom_order: true applies the combined run in the plan's stored order, false in the
canonical order. The job becomes the plan's last_appraisal_job_id in the same commit, which is
where GET /v1/adaptation-plans/{id} reads appraisal_status and avoided_loss. A plan never
relaxes the base refusal above.
Frequency curve range. GET /v1/results/{id}/freq-curve?rp=N answers the sampled return periods inside [RP_min, RP_max]; any other rp is 422 naming them
in details.return_periods (with details.range; a dataset without return periods answers no
rp, and no rp returns the full curve; amended by #1596, PML is read, never interpolated), and the engine specification's two readings of
the curve below the smallest modelled exceedance (losses[0] at §4 versus 0 at §9) are settled
in the engine repo before the UI promises a figure there.
Provenance (worker/tasks.py result provenance): add scope, exposure_growth,
measure_ids, exposure_dataset_ids, vulnerability = {impact_function_id, version, resolved_on}.
scope is recorded in its stored form, so a clip of several units names every unit.
A run in flight (EX-20, BC-6, BC-7). "Run N cells" answers 202 with a batch_id
(api/v1/compute.py:749, 904), and every cell is an ordinary impact job on the queue. The run
therefore belongs to the server and not to the page, and it keeps going when the user leaves
Explore. GET /v1/compute/impact/matrix/{batch_id} serves the batch's status and, per cell,
status, result_id and error_message (api/v1/compute.py:1017-1058,
core/compute_batch_service.py:940-970). That read is what the address key batch reloads (§7).
Neither a job row (api/v1/jobs.py:19-38) nor a batch cell carries a progress fraction, so the UI
shows "cell k of N" and a time estimate that says it is an estimate, and never a progress bar.
Nothing blocks navigation (BC-6). Code check of 2026-10-05: the API is one uvicorn process
(docker/core.Dockerfile:69), prod runs one compute worker at concurrency 1
(docker-compose.hetzner.yml:49-50), and impact compute shares its queue with ingest
(worker/celery_app.py:90-98) until EN-1 gives it its own. The proof is two acceptance boxes, not
a contract field. On the stack, while a matrix run is in flight, the Explore end-to-end test opens
Data, Portfolios and Lookup, gets a Lookup answer, returns to Explore and sees the run's progress.
On prod, with EN-1, a Lookup request answers within its normal time during a matrix run.
Jobs for the pill (BC-7). Code check of 2026-10-05: GET /v1/jobs filters one status per call
(api/v1/jobs.py:72), and a job is in flight while it is pending or running
(models/enums.py:103-109, the UI words pending as queued). Who started a run is stored and not
served: created_by_user_id on the batch (models/compute_batch.py:48, written at
api/v1/compute.py:883) and user_id inside a job's params (api/v1/compute.py:180), and
neither the batch list row (api/v1/compute.py:1000-1011) nor the job row
(api/v1/jobs.py:19-38) carries it. One new backbone issue in W2 (#1662) closes the gap with three
changes:
- The batch list row and the batch detail gain
created_by_user_id, read fromcompute_batches.created_by_user_id. - The job row gains
created_by_user_id, read from theuser_idof the job's params. A run of one cell has no batch, so the pill needs it there. GET /v1/jobsaccepts a repeatedstatusparameter (?status=pending&status=running), so one call returns every job in flight. Onestatuskeeps its meaning of today.
With these the top bar pill and History's "Running now" read one shared poll, and "Yours" is the
rows whose created_by_user_id is the viewer. "Cell k of N" needs no new field: the batch list
row serves cell_count, and the batch detail serves every cell with its job id and its status
(api/v1/compute.py:1045-1056). A batch_id on the job row is therefore optional. The issue adds
it only if it shows that the pill would otherwise read one batch detail per batch in flight.
The same issue names three more fields (2026-10-07). GET /v1/jobs serves impact_workers beside
total, the impact worker slots, read from the setting impact_worker_concurrency (env
IMPACT_WORKER_CONCURRENCY) that the prod impact worker's --concurrency also reads, so one value
feeds both. It is null when the setting is unset, and History draws its line about busy workers
only from this served total. Each cell of GET /v1/compute/impact/matrix/{batch_id} serves
hazard_dataset_id, null until the cell resolves, and cached (see "Matrix cells reuse stored
results" above).
As landed (#1662, 2026-10-10). The batch list row, the batch detail and the job row
(GET /v1/jobs and GET /v1/jobs/{id}) carry created_by_user_id and, beside it,
created_by_email, the e-mail of that user in the caller's organisation (users has no name
column), null when created_by_user_id is null or the user is gone (#1673 item 48). A job's
created_by_user_id is its params' user_id, or owner_user_id for an upload ingest, and null
for a feed job, so the pill tells the viewer's own jobs from the organisation's. GET /v1/jobs
takes the repeated status and serves impact_workers in its envelope beside total. Each matrix
cell serves hazard_dataset_id (the dataset it resolved to, read off its job's params, null for a
cell that resolved none) and cached (true when its completed job's result was written by another
job). No migration: the batch column, the job params and impact_results.job_id already hold every
value.
As landed (#1654). The job row (GET /v1/jobs and GET /v1/jobs/{id}) also carries cancellable
and ingest_job_id, so the pill and History's "Running now" know which rows offer Cancel and
through which route. A pending impact_calc job is cancellable through
POST /v1/jobs/{id}/cancel. A hazard ingest's job serves the ingest_job_id its params carry and
is cancellable while that ingest_jobs row is not terminal; it is cancelled through
POST /v1/hazards/ingest-jobs/{ingest_job_id}/cancel, which writes the ingest lane only. Once that
row reads cancelled the job is served status: "cancelled", derived at read time and never
written to jobs.status, and the status filter matches the served value. Every other row,
the centroid-assignment job included, is not cancellable. A waiting exposure upload's Cancel is
1667's.¶
Stop. Stop cancels the remaining cells of a run and keeps the completed ones. JobStatus gained
cancelled with #1639 (migration 0116), and POST /v1/jobs/{id}/cancel landed with #1654: a
pending impact_calc job ends cancelled and its task is revoked, and any other state answers
409. Stop is one POST /v1/jobs/{id}/cancel per cell of the batch that has not started. A cell that has not started is cancelled. A cell that is already
running finishes and keeps its result, because the backbone never terminates a running task. The
batch row stays, so batch=<id> still opens the run with its completed cells.
Stop arrives in W6, with ui#844. The cancel route (#1654) and the cancelled job state (#1639) stay
in W6, because #1639 owns the migration 0116 (job_status_enum gains cancelled) at the end of
the phase's one migration chain and moving it would renumber the migrations of ten issues. Until W6 no Stop
control is shown, in the Run row or in any list of running runs. The W2 work of EX-20 is the pill,
the shared poll, the run that survives navigation, the batch= reload, the toast, the bell item
and the three backbone changes of "Jobs for the pill" above.
Saved work (BC-5, EX-21, EX-22). A saved item is a Scenario. Code check of 2026-10-05: the
write side is open, ScenarioCreate.inputs is a bare dict, so any key round-trips and no shape
fails a create (schemas/scenario.py:75). The read side is typed. ScenarioInputs declares
exposure_dataset_id, hazard_dataset_id, impact_function_id, scenario_label,
horizon_year, engine, measure_ids, batch_id, comparison_id, discount_rate and
growth_rate (schemas/scenario.py:57-67). An undeclared key is reported as unknown_input_key
and ignored (core/scenario_service.py:85, 120-122). A blob with no hazard_dataset_id is
reported as missing_hazard_dataset, which marks the scenario as not replayable
(core/scenario_service.py:92-95, 127-135). One Scenario links one impact result through
result_id (models/scenario.py:42-46). The rules that follow:
- Every run key. A saved run stores the run under the names of this contract:
portfolio_idorexposure_dataset_ids,scope,haz_type,cells,measure_ids,impact_function_id,engine, the growth rate asgrowth_rate(K20) and, for an adapted run,adapted_from_result_id. No field has to be added for the save to round-trip, because the write side is open andGET /v1/scenarios/{id}returnsinputsas stored (schemas/scenario.py:105). Four of the keys are declared already (impact_function_id,engine,measure_ids,growth_rate). The read view declares the other six (portfolio_id,exposure_dataset_ids,scope,haz_type,cells,adapted_from_result_id) so that it stops reporting them as unknown, with no migration becauseinputsis JSONB.missing_hazard_datasetis raised only when a blob names neitherhazard_dataset_idnorhaz_typewith an exposure selector, because this contract resolves the dataset from those two. Thescenario_idreplay ofPOST /v1/compute/impact(#1186) replays one cell, so the UI re-runs a saved run by restoring the form and pressing Run (§7, EX-21). - The batch id.
batch_idis declared (schemas/scenario.py:64). No field is added. - Which cells completed. No key holds it and none is added. The saved
cellslists the completed cells only, so a reopened run restores exactly the cells that hold a result. The total and the failed cells of the run are read from the batch throughbatch_id, which serves every cell with its status, its result id and its error. A saved run of several cells links its results through the batch, becauseresult_idholds one. - A comparison's two sides.
comparison_idis declared (schemas/scenario.py:65) and names an engine comparison group, whichGET /v1/compute/impact/compare/{comparison_id}reopens. A comparison of two cells has no declared key for its sides A and B, nor for the mark that it is a comparison. The open write side stores them unchanged whatever key holds them. - An event replay's watch. No declared key names a watch, and
result_idcannot link an event loss, which is stored apart from impact results (ADR-078). The open write side stores the watch's id unchanged whatever key holds it.
The keys are decided (2026-10-07, #1663 with ui#872 and ui#868, K55). A comparison's sides are
the saved key compare = {a, b, engines}. A comparison of two cells stores a and b in the
cell grammar (baseline@today, ssp5-8.5@2080) with engines: false, and an engine comparison
stores compare = {a, engines: true} beside its comparison_id. A saved event replay stores
live_event_id, its watch, and event_key, the one event its figures sit inside (EV-11), and no
loss figure. A saved run's cells are stored in the matrix request's form,
[{scenario_label, horizon_year}] with horizon_year null for Today. ScenarioInputs declares
compare, live_event_id and event_key beside the six run keys above. GET /v1/scenarios
gains two optional filters, live_event_id=<id> (the rows whose inputs.live_event_id equals it)
and mine=true (the rows the caller created), so a second "Save replay" of one watch by one user
finds the row to replace. A saved replay names neither hazard_dataset_id nor haz_type, so it
keeps missing_hazard_dataset and reads computable: false, and History reopens it in Events on
its watch.
Stored results of given run inputs (EX-10, RP-2, #1664, 2026-10-07). GET /v1/results answers
which results are already stored for the inputs of a run, so the Summary tab's "By hazard · this
cell" and a report widget read a figure only where one exists. No list route existed before
(api/v1/results.py holds the /{result_id} routes only). The query is the selector
(portfolio_id, or exposure_dataset_ids repeated), the clip (admin_boundary_ids repeated, or
bbox=w,s,e,n), an optional haz_type, optional cells repeated in the cell grammar,
exposure_growth_rate (default 0), measure_ids repeated, impact_function_id and engine. It
matches the stored jobs' request params under this contract's names, the same inputs the cache
key reads, and an omitted impact_function_id or engine matches what a run of those inputs
would use. It answers {items: [{result_id, haz_type, scenario_label, horizon_year, ead,
value_unit, value_weighted_average_band, indicator_score, created_at}], total}, one item per
hazard type and cell with the newest stored result, and no item where none is stored. Only
cell-addressed results are listed. An indicator hazard has one item per requested cell with
indicator_score ({band, label, metric_unit, hazard_dataset_id, rows_sampled}) and null
result_id, ead and created_at. The read is scoped to the organisation and never queues a job.
As built (core/stored_results.py), GET /v1/results?exposure_dataset_ids=<id>&cells=baseline@today
refuses the selector and the clip exactly as /impact does (422 "name one exposure selector", the
§ 6 clip refusals, a 404 for another organisation's portfolio). An omitted curve is each hazard's
(haz_type, exposure_type) default (§ 3.5), and an omitted engine the default engine; a job that
recorded no engine_name reads as the default. An indicator hazard is one whose candidates in the
selector's countries (the region-less resolution above, over the unclipped selector) hold an
indicator dataset. It answers a row for each requested cell that no stored result answers, its
indicator_score sampled by #1665's indicator_scores at the selector's 100 largest rows inside
the clip (rollup_max_portfolio_members), null when the clip leaves no row. Without cells the read
lists no indicator row, because a score is sampled for a named cell.
Bands per location and per hexagon, and one asset's or one unit's figures per hazard (EX-14, #1685,
2026-10-10). A result's archive holds band_exp beside eai_exp: one integer per location in the
same compute order, 1 to 10, and 0 where the location has no band (a sentinel row, or no intensity at
the design return period). It is the band band_distribution counts for that location, from the
same banding pass. The result tiles serve it as band on each assets feature, paired exactly as
eai is, and each result H3 cell holds band_max (the highest band of its banded points) and
band_value_weighted (Σ band × value over Σ value, the weighting of value_weighted_average_band)
in result_h3_cells.metrics, served per h3 feature. A feature with no band carries no key, and a
result written before this carries none: nothing is backfilled and no band is guessed. The
answer-plane surfaces hold neither key. Two reads serve the per-hazard figures of the map's cards,
each with the query of GET /v1/results above less haz_type, and each matching, scoped and
refused as that read is:
GET /v1/results/assets/{exposure_id}answers{exposure_id, items: [{haz_type, scenario_label, horizon_year, result_id, eai, band, value_unit, created_at}]}: one item per hazard type and cell with a stored result,eaithe asset's entry of that result'seai_expandbandits entry ofband_exp(null for 0, and for an archive with noband_exp). An exposure id that is not one of the selector's rows inside its clip is 404E_NOT_FOUND. A result whose exposure set drifted from its array gives no item.GET /v1/results/admin-units/{boundary_id}answers{boundary_id, admin_level, items: [{haz_type, scenario_label, horizon_year, result_id, eai, points, value_unit, created_at}]}: one item per hazard type and cell with a stored result holding aresult_admin_cellsrow for the unit, with that row'seaiandpoints. An unknown boundary is 404E_NOT_FOUND. The unit has no result band: a band there needs a column, so the admin card keeps its screening band (K61).
Neither read computes, queues a job or adds two hazards (K26), and an indicator hazard has no item in either, since no stored result holds one.
Uploads run as jobs (DT-7, PF-12, #1667, #1670, 2026-10-07). POST /v1/exposures stages the
file in object storage, enqueues a data_ingest job whose params carry the upload's kind and
the uploader's user_id, and answers 202 with {job_id}, where before it parsed the file inside
the request and answered 201 (K55). A reused name with no supersedes_dataset_id keeps the
idempotency of #550 and answers 200 with {job_id: null, skipped_existing: true, message}. While
the job runs, its row serves step: {index, count, name} with index from 1, so the pill,
History's "Running now" and Data's bar follow the upload (EX-20). A waiting exposure upload is
cancelled through POST /v1/jobs/{id}/cancel (#1654) and ends cancelled (#1639), and a running
one answers 409. The upload form gains country (ISO3), and a file whose points derive another
country fails its job with E_VALIDATION. It gains reference_year, stamped into each row's
properties.reference_year, the year the growth row above compounds from. It gains
supersedes_dataset_id for a new version, and portfolio_id, portfolio_name and
portfolio_owner for a portfolio import. The dataset read then serves portfolio_id and
supersedes_dataset_id. POST /v1/hazards/upload takes supersedes_dataset_id too. #1670 adds
event_date and netcdf_variable to the hazard upload, POST /v1/hazards/footprints for an
event outline and POST /v1/admin-boundaries/uploads for a boundary file held for review
(domain model § 1).
SDK. sdk/python request models gain exposure_dataset_ids, scope, exposure_growth_rate,
measure_ids, adapted_from_result_id and cells after the field pieces, in W2 (#1601), and the
SDK smoke covers one clipped, grown run. No release tag lands between the first field piece's merge
and that one.
Migrations. None for the run contract. exposure_type, value_unit, properties,
temporal_coverage_end and the JSONB metadata on results exist. The cancelled job state of EN-4
brings its own migration in W6 (#1639), and the upload links of #1667 bring revision 0116 in W6.
Tests. Unit: growth exponent with a recorded reference year and without, every refusal text
above, the cache key with and without the new fields. DB: a clipped run's scope_total equals the members
inside the clip and /per-exposure rows all lie inside it. A clip of two units of one level and
one country counts their union once, and the one-element alias gives the same scope_total, the
same stored metadata.scope and the same cache key as the one-element list. Worker: a stationary hazard at 2050 with
g = 0.02 and reference year 2018 has EAI 1.02^32 times the ungrown run's. API: /compare accepts
the same body as /impact.
7. The Explore dock¶
The dock is two numbered steps, 1 Scope then 2 Run (K36, K48, owner, 2026-10-05). History:
on 2026-10-02 the owner chose to keep the prototype's three tabs, Scope, Layers and Analysis, with
their content redefined (K13). K36 reversed that choice on 2026-10-05. Layers left the dock for a
button in the map's control group, and the step that was "Analysis" is named "Run". The inputs of a
run and their order (K23) did not change. Every control writes URL state
through src/actions (D11) and appears in the assistant's manifest.
1 Scope · "What do you want to assess?". (K38, EX-18.) The head shows the question and under it
the resolved line, or "Nothing chosen yet". A two-part switch under the head opens one of two
doors, "A place" and "My assets". With nothing chosen "A place" is open. With an exposure chosen
the door that matches it is open: datasets: opens "A place", portfolio: and asset: open "My
assets".
- A place. Step 1 "Where": one chip per covered country from coverage, several may be on, then
"Narrow it (optional)" with Whole country, Region, Municipality and Draw area while exactly one
country is on (§3.2). Step 2 "Exposure layer": one card per country, filled in with the
default_dataset_idthe list route returns for that country (§3.1, §6), reading the layer's name and "· locations · reference year ". "Change" opens the layers of that country inside the card and reads "Close" while the list is open. With two or more countries on, "Hazards for together" follows (§3.3). A click on the map sets the place while this door is open: a country chooses it, and a region or a municipality inside the one chosen country narrows it. - My assets. A three-part choice of Portfolio, Pins and One asset, then "Upload a portfolio", then "Narrow to a place (optional)" (§3.2).
The resolved line reads "Greece · Attica · LitPop Greece (USD) · 12,480 locations" for a place,
"Greece · Attica, Thessaly · LitPop Greece (USD) · 31,204 locations" for several units, and
"
URL keys: exposure = portfolio:<id> or datasets:<id>,<id> or asset:<id> (saved
pins become a portfolio through D17, so they take the first form), clip = admin:<id> for one
unit, admin:<id>,<id> for several units of one level and one country, or
bbox:w,s,e,n. set_scope keeps kind and id for the geography kinds and gains exposure (the
same grammar) and clip.
Legacy keys are read once and rewritten with replace:
scope=portfolio&id=X→exposure=portfolio:Xscope=country|region|municipality&id=X→clip=admin:<boundary id of X>scope=area&bbox=…→clip=bbox:…sc=S&horizon=Y→cells=S@Y, and an absenthorizon→baseline@today
scope, id, bbox, country, sc and horizon leave EXPLORE_KEYS (exploreState.ts:29-61),
and every in-app link, the Portfolio detail's "Analyse in Explore" first, writes the new keys.
Layers, a button on the map. The map's control group holds a Layers button that opens a popover, headed "Layers" with the line "Map display only. Nothing here changes the run." It holds the surface of the hazard chosen in Run, on by default at the design return period, its return period (the dataset's own), the surface opacity, results on or off, the scope's assets, event footprints and "Add a layer from Data". The palette stays in the map's own palette menu. No input to the run lives here. The prototype's scale test is a demo, not a target.
2 Run. The form, in the order K23 fixed:
- Hazard. One row that opens the grouped list (K39, §3.3), coverage-gated.
- Scenario × horizon. The matrix of cells, Today always present. Each cell has a tick and a body (K51, EX-3 as amended). The tick puts the cell in the run or takes it out, and never changes what the map shows. A click on the body of a computed cell shows it on the map and never changes the selection. A click on the body of a cell that is not computed adds it to the run. A key line under the matrix reads "Tick: in this run" and "Outline: shown on the map".
- Damage curve. The select with its preview (K47, §3.5).
- Exposure growth. A number input in % per year, with the hint "Uniform growth from
( ), existing locations only. A Today cell is never grown." - Adaptation measures. Chips, "Add measures" and "Use a saved plan" (K40, §3.7).
- Engine, with Compare engines.
- Run. One primary "Run N cells" with the progress grid under it. A completed tile of the grid shows its cell on the map.
A non-climate hazard's future cells read "hazard same as
today". A non-money exposure replaces Run with the score-only reason and shows no damage curve
select. A 422 shows its reason and
candidates (EX-2). URL keys: hazard, cells (comma list of scenario@year), impf, growth,
measures, engine, and batch once a run was started.
The caption over the map (K37). One line replaces the four menu chips of EX-9: "Showing", then
the hazard (icon and name, plain text), then scenario and year as one menu button, then the engine
(plain text), then a status word such as "not computed yet" or "running", and none when the viewed
cell is computed. Only scenario and year open a menu. It lists the computed cells of the current
inputs, changes which cell the map, the strip and the panel show, and never changes the run's
cells. With nothing computed it reads "No computed cell yet". With the dock collapsed the caption
carries "Edit run", which reopens the dock on Run. With Scope open there is no caption.
The strip (K52, EX-10 as amended). Five figures: Locations, Exposed value, Expected annual impact, 1-in-N loss and High or severe. The third label reads "Expected annual impact". Every figure has an info button that opens a popover titled "How this is made" with three lines, What, How and Use, and, where the result holds them, the dataset, its return periods and the curve. The copy to ship:
| Figure | What | How | Use |
|---|---|---|---|
| Locations | The exposure rows inside your scope, after the clip. | Counted when the run starts. | The size of what you assess. A clip that leaves no rows has nothing to compute. |
| Exposed value | The sum of the values of those locations, in one unit. | Each row's value from the exposure layer or your upload. With growth, values are scaled from the layer's reference year. | The denominator. Every loss can be read as a share of it. |
| Expected annual impact | The average loss per year over a long time, for this hazard. | For each location, the damage share from the damage curve at each event's intensity, times the value, times the event's yearly frequency, summed. With return-period maps this is the area under the loss curve. | The basis of a technical premium or a yearly budget. It is not a typical year. Hazards are not summed. |
| 1-in-N loss | The loss exceeded with a 1-in-N chance in any year. | Read from the loss curve at N years. With return-period maps every location takes its own 1-in-N intensity at once, so it is an upper bound for a spread-out portfolio. Only this dataset's return periods are offered. | Sizing capital and reinsurance. |
| High or severe | The share of exposed value in the two worst score bands. | Each location's hazard intensity at the design return period is banded from 1 to 10, and bands 7 to 10 count. | Concentration. It works even when no money loss can be computed. |
A run in flight (EX-20, K42). Pressing Run registers the run and writes batch=<id> into the
address. The run keeps going when the user leaves Explore. Coming back, or opening the address
with batch=<id>, shows its progress grid or its results without pressing Run again (§6). While
it runs, the Run row reads "Running · cell k of N" and cannot be pressed twice, and the line under
it gives a time
estimate and says "The run keeps going if you leave Explore." The top bar shows a pill such as "1
running" while a run of the viewer's is in flight. The pill opens a short list headed "Yours", each
run with "cell k of N", a time estimate labelled as an estimate and Open, then one line for the
rest of the organisation. The backbone serves no progress fraction, so no progress bar is drawn
anywhere. One shared poll serves the pill and History's "Running now". A finished run of the
viewer's raises a toast with Open and an item in the bell. Nothing blocks navigation while a run
is going (§6). All of this is the W2 work of EX-20, with the backbone keys of §6 "Jobs for the
pill".
Where a reopened run ran (EX-28, #1806). A run reopened from the pill, the bell or its
batch=<id> address refills the dock from the batch. The batch row holds the clip as
params.scope, in the stored form its cell jobs get ({"admin_boundary_ids": [...]} with the ids
sorted, or {"bbox": [w, s, e, n]}), and GET /v1/compute/impact/matrix/{batch_id} serves it
with the rest of params. It is written when the run is submitted, so a run in flight, and one
whose every cell failed, still say where they ran. A run with no clip has no scope key. A batch
written before #1806 has none either, and the dock then reads a completed cell's
result.metadata.scope (ui#1069).
"Stop" is the one part that arrives later, in W6 with ui#844. It sits beside the running Run row
and sends one POST /v1/jobs/{id}/cancel per cell that has not started. A running cell finishes
and keeps its result, and the completed cells keep theirs (§6). Until W6 no Stop control is shown,
in the Run row or in any list of running runs.
Saving a run of several cells (EX-22). "Save analysis" stores the batch id and the completed cells only (§6). The toast says how many, in the pattern "Saved 3 of 4 cells (1 failed)", and names History as the place the item is listed. Save is off, with its reason, until at least one cell is completed.
Reopening saved work (EX-21). "Open in Explore" on a History item restores the whole run. It
writes every key of this section into the address, exposure, clip (a list of units included),
hazard, cells, growth, measures, impf, engine and batch, and the stored cells read
as computed. "Re-run" does the same and starts the run. A saved comparison opens in Compare with
its two sides, and a saved event replay opens in Events on its watch.
Compare (EX-11 as amended, FT-21). Save stores a comparison with its two sides. The address
carries the sides as a and b, or a with engines=1 for an engine comparison, so a copied
link reopens the comparison. A side that is not computed runs in place, and while it runs its
button says so and cannot be pressed twice. In Compare mode the dock shows one compact "Comparing"
card with the two sides in place of the run form. With no exposure chosen the empty state offers a
button to Scope.
Actions. set_scope gains exposure (a string in the URL grammar, portfolio:<id> or
datasets:<id>,<id> or asset:<id>) and clip (admin:<id>, admin:<id>,<id> or
bbox:w,s,e,n), keeping kind
and id for the geography kinds. set_analysis gains cells, growth, measures, impf.
compute_cell reads them all, translates exposure into portfolio_id or exposure_dataset_ids
and clip into scope (a list of units becomes admin_boundary_ids),
and sends one matrix POST. The manifest
and the URL bridge round-trip every new argument, and a registry vitest dispatches each.
The map. With Scope open the map shows exposure only (EX-14 as amended, FT-3). Scope is open
when Explore is in Analyse mode, the dock is not collapsed and the Scope step is in front. The map
then draws the scope assets layer (admin tiers by value below z 9, then hex bins by value below
the points zoom, then points, from /v1/tiles/exposures/{id} or /v1/tiles/portfolios/{id}, whose
layer=admin sums the value per unit, #1673 item 37), fitted to the scope's
bbox, with no hazard surface, no result tiers, no caption and no horizon player. The legend reads
the exposed-value ramp in the exposure's unit, or "Mixed units". Leaving Scope restores what was
shown. With Run open and before a compute, the same layer sits under the hazard surface. After a
compute the result tiers draw by zoom with the EX-8 legend and
per-tier popup cards (EX-14). The clip is outlined, every unit of a list included, and a drawn
area's rectangle is on the map only while the clip is that area. The level switch is visible
throughout.
Phones (D12): the dock becomes a bottom sheet with the same two steps, the Layers button stays on the map, and the map chrome collapses. The Events player stays on the map as a compact player with no fold (K67).
8. What Lookup computes¶
Lookup is a hazard screening of one point, not a loss run (owner validation K15).
- For every hazard type, the backbone picks one dataset whose footprint covers the pin (ADR-079), matching the requested scenario (default baseline) and horizon, by precedence (ADR-056): quality, locality, extent, recency.
- It reads the intensity at the pin from the dataset's cloud-optimised raster at the design
return period: 100 years, 476 years for earthquake (
config.py:811,scoring.py:158-168), plus every return period the dataset serves. - It bands the intensity through the versioned score scheme for that hazard and unit (ADR-043,
ADR-073, the seeded
score_schemes), a 1 to 10 score with a label. - The UI forms the headline as the worst banded hazard (
lookupSummary.ts), the four hazard groups, and "n hazards not covered" from coverage (D6). - The UI derives the chances for this year, 15 and 30 years from the dataset's return periods
(
chances.ts): the annual probability of reaching the threshold by log-linear interpolation, then1 − Π(1 − p_y)over the years, with the scenario's trajectory between the anchors. - No exposure or asset value enters at any step (
lookup_service.py:1432-1448). A monetary damage line, as the prototype illustrates, needs a per-building hazard resolution the data does not have (D18) and is Phase 18's Edit home.
Exposure-based figures for a place come through "This area", the admin-unit rollup of saved runs (LK-15: per hazard from the newest Today result that covers the unit, never summed), and through Portfolios. The "How this score is made" block (LK-13) prints steps 1 to 3 and 6 from the card's own provenance.
"This area" per hazard (LK-15, #1684, 2026-10-12). The read is
GET /v1/risk/admin-units/{boundary_id}/impact, beside the score rollup
GET /v1/risk/admin-units/{boundary_id}, which it does not change. It answers
{boundary_id, admin_level, hazards: [{haz_type, result_id, scenario: "baseline", points, eai,
value_unit, exposure_dataset_id, exposure_name, exposure_type, computed_at}], buildings_in_data}.
One stored result stands for each hazard: the newest completed Today run (cell baseline@today,
no measures, no growth) of the caller's organisation that covers the whole unit (unclipped, or
clipped to the unit or one of its ancestors) and holds a row for the unit, a run over the
country's default exposure layer (§ 6, EX-18) first. eai and points are that result's
result_admin_cells row for the unit. exposure_dataset_id is null for a portfolio run,
exposure_type is null when the run's locations hold several, and buildings_in_data is the
points of the entry over the default exposure layer, else of the newest entry, and null when
hazards is empty. It computes nothing, queues no job and sums neither hazards nor exposures
(K26): a portfolio and an exposure layer over the same ground would count the same buildings
twice.
From a pin to a run (LK-14, BC-8). A click on the Lookup pin opens a place card with Place
report, Watch and "Open in Explore". The last one opens Explore on the door "A place" for the
pin's country, on its default exposure layer, clipped to the municipality that contains the pin,
with the hazard being read. Code check of 2026-10-05: the Lookup card's location block names the
country only, country_iso3 and country_name (core/lookup_service.py:700-708), resolved by
country_at_point (db/repositories/admin_boundary_repository.py:472). GET /v1/admin-boundaries
filters by level, iso3 and name, and its geometry route by a viewport box
(api/v1/admin_boundaries.py:32-49, 77-99). GET /v1/geocode/reverse answers a provider address
with no boundary id (api/v1/geocode.py:66). So no read names the municipality of a point today.
The rule: the location block of the Lookup card gains the admin units that contain the pin, one
per loaded level, each with the boundary_id and name that GET /v1/admin-boundaries already
serves, by the same point-in-polygon read. That adds no route. Where the block names no
municipality for the pin, the link opens the country without a clip. The field is
location.containing_units: [{admin_level, boundary_id, name}] on the Lookup card
(GET /v1/risk/lookup, #1668, 2026-10-07): one entry per loaded admin level whose unit contains
the pin, ordered by admin_level ascending, and [] when no boundary is loaded there.
country_iso3 and country_name keep their meaning. The card reads the country and the units in
one query, units_at_point (db/repositories/admin_boundary_repository.py): the country first, by
country_at_point's tie rule (iso3, then name), then at each deeper level the first unit by
name among that country's units that contain the pin. A pin with no loaded country names no unit.
9. How Adapt applies¶
Measures are a run input (§3.7, AD-1). Running with measure_ids produces an adapted result with
tiles and per-location EAI, so the adapted map, the adapted portfolio and the adapted area all come
from ordinary results. Where the plan saves loss is a difference of two of them (AD-7): the adapted
result's tiles take ?minus=<base result id>
(GET /v1/tiles/results/{adapted}/{z}/{x}/{y}.mvt?minus={base}) and every admin, h3 and
assets feature then carries eai_avoided, the base's eai minus the adapted one for the same
boundary, H3 cell or exposure, beside the eai it already had. minus must be the adapted
result's own adapted_from_result_id (§6); any other result is refused with 422, and a base whose
cells are not built has no difference tiles. A feature the base holds no row for carries no
eai_avoided, and on assets the tile's cap keeps the points with the highest adapted EAI.
Adapt, as a page and as Explore's fourth mode, is the appraisal layer: it
takes the unadapted Today result as base, the Explore form's growth as default, a discount rate and
an appraisal period, calls cost-benefit for the selected measures against the climate-adjusted
future hazard (#1482), and shows avoided loss, BCR, payback and the evolution chart. Entry points
from a portfolio, an asset, a saved analysis (through Open in Explore and the Adapt mode), Lookup's "Model the benefit" and Home carry the scope
(AD-1). With nothing chosen Adapt opens on a landing page that asks "What do you want to adapt?"
through the same two doors as Scope and lists the saved plans (AD-17,
domain model § 4.3). A measure whose hazard differs from the run's is refused (AD-5), and a protection standard
is a return period: the event rows with rp <= RP_protect are removed (AD-2, as amended). An
event takes measures too (AD-9): POST /v1/live-events/{id}/compute with measure_ids and
adapted_from_result_id (the unadapted event result's job) stores an ordinary adapted event result,
and its result read serves avoided_event_loss, the unadapted result's event_loss minus the
adapted one, in the same unit, computed on read. An event has no BCR, payback or annual figure
(§11), a protection standard on an event is refused with 422 because a footprint has no return
period, and an adapted event result is never the watch's headline. The
measure entity, its catalogue, lifetimes, combined plans, masked application and the engine items
behind them are in phase-17b-domain-model.md § 4.
10. The assistant runs a scenario¶
The assistant acts only through named actions (ADR-086). A run starts from what is at risk, so it first decides whether the user named a portfolio or a place. It must be able to complete both requests below.
A portfolio (AS-2): "Run river flood today for
find_portfolios("<portfolio>")(new query tool) → the id.open_view explore, thenset_scope {exposure: "portfolio:<id>"}.list_hazard_options({portfolio_id})(new) → river flood is computable, its Today cell exists.set_analysis {hazard: river_flood, cells: ["baseline@today"]}.compute_cell(Run or Skip in the trace). The UI waits for the job through RunProvider, bounded at 180 s, and posts the terminal state as the tool result.get_job(job_id)(new) →result_id, thenget_result_summary(result_id)→ the figure and itsvalue_unit(AS-5, #1650): the unitGET /v1/results/{id}serves,nullwhere the result's rows mix units.- The reply quotes the served figure only (the unsourced-figure guard, #1495), in the served
value_unit, and names no unit when it isnull.
A place (AS-4, BC-4): "Run river flood today for Greece and tell me its expected annual
impact". The calls come in exactly this order: find_exposure_datasets, open_view, set_scope,
list_hazard_options, set_analysis, compute_cell, get_job, get_result_summary.
find_exposure_datasets({country, region})(new, a read-only query tool) → the exposure layers assigned to the country, each with its id, name, unit, type and the default flag. The tool callsdefault_exposure_dataset(§6) and flags the row it returns, the same row the list route names indefault_dataset_id, so the assistant and the door "A place" can never pick two different defaults.regionis optional and names a region or a municipality inside the country. With it the tool also returns the clip of that unit, as the prototype's tool does.open_view explore.set_scope {exposure: "datasets:<the default id>", clip: ""}.set_scopetakesexposurein thedatasets:form and aclipthat may list several units (admin:<id>,<id>). For a whole country the clip is empty. For "Run river flood today for Attica" it is the clip the tool returned for that region.list_hazard_options({exposure_dataset_ids: [<id>]})→ river flood readsstate: coveredand its Today cell exists.set_analysis {hazard: river_flood, cells: ["baseline@today"]}.compute_cell(Run or Skip in the trace), awaited as in the portfolio sequence.get_job(job_id)→result_id.get_result_summary(result_id)→ the figure and itsvalue_unit. The reply quotes the served figure only, in that unit.
A report's Draft (PF-6, #1650; the UI half is ui#838) is one assistant turn through the same
endpoint. get_portfolio_rollup(portfolio_id, scenario?, horizon_year?) (a read-only query tool)
answers the portfolio's served rollup: name, member_count, the headline band, per hazard the
typical band and the worst band, value_weighted (combined_score, band_distribution,
share_high_or_severe, unit) and ead_by_hazard, read by the function a report's
portfolio-rollup section reads, so a drafted commentary quotes the section's figures instead of
[figure withheld]. A portfolio mixing units answers unit: null.
For a set of several countries list_hazard_options does not refuse the call. It returns the
state and the reason of every hazard type (§6), the same values the read route gives the
picker. The assistant runs a hazard only when its state is covered. For no_single_dataset it
quotes the reason and offers to run one country, and for not_covered it quotes the reason and
runs nothing.
The UI posts context on every turn as
{view, exposure, clip, hazard, cells, growth, measures, impf, engine, job_id, result_id, lens},
the same names as §7's URL keys, the second half of SH-5. list_hazard_options takes the exposure
selector (portfolio_id or exposure_dataset_ids), not a country, and answers from the same
resolution rule as §6. No fake provider exists (K21). Backbone tests
inject a scripted adapter and assert both call sequences. The e2e intercepts only
POST /v1/assistant/messages and lets compute, jobs and results hit the real smoke backbone, then
asserts the quoted figure equals GET /v1/results/{id}. A live-provider run is a credential: ops
item on the wave gate that fails, never skips (guardrail 3).
11. Honest limits the UI states¶
Exact copy, so builders do not invent softer wording:
- "Hazard same as today: this hazard is not climate-driven. The scenario changes only the exposure."
- "Growth scales existing locations from
. No new locations enter hazard zones." - "Vulnerability: one curve per hazard and exposure type. Per-building curves are not modelled."
- "Loss curve basis: return-period maps, the whole footprint at once. An upper bound for a dispersed portfolio. No stochastic event set."
- "Score-only exposure: no money loss can be computed for
." - "Expected annual impact is per hazard. Hazards are not summed."
- Lookup: "No exposure or value enters this score. For a portfolio's loss, open it in Explore."
- "Event loss: ground-up loss of one observed footprint, not annual." Shown wherever an event loss and an expected annual impact can appear on one screen (Home, History, Reports).
- "Mixed units:
members, no single exposed value." A portfolio's exposed value is shown only in its one unit. - Screening-grade figures (coastal flood, earthquake, event loss) carry the grade badge on cards, not only in drawers.
- "n hazards not covered" (Lookup) and "n hazards not scored for portfolios" (Portfolios, Home) come from coverage on every surface that scores.
- Portfolios: "value-weighted average band (ordinal, screening)" on the rollup's average, and "typical intensity across the portfolio" on its per-hazard figure.
The copy says "portfolio" for the user's own assets and "exposure layer" for a reference dataset, and never "book" (K49). The fine-tune of 2026-10-05 adds these lines:
- A country with no layer, under the door "A place": "No exposure layer for
yet", with "Add one". - Several countries and a hazard with one dataset per country: "No single
dataset covers . One run never stitches per-country hazard files.", with "Run one country". - Narrowing a place: "Regions or municipalities of one country, one level at a time."
- A run in flight: "cell k of N" and a time estimate that says it is one, in the form "about
min left (estimate)". No progress bar, because the backbone serves no progress fraction. - Saving a run of several cells, as a pattern: "Saved 3 of 4 cells (1 failed)". The count of completed cells comes first, then what happened to the rest. Save is off, with its reason, until one cell is completed.
- The caption's menu with nothing computed: "No computed cell yet".
- Reports, a widget with no stored result: "Not computed yet", with "Run it in Explore". A report reads results and never makes a figure up (RP-2).
12. Acceptance¶
The checks /phase-split lifts into the issue boxes. Each names the command form and the expected
output.
EX-15 (backbone contract)
- pytest tests/test_api/test_compute_run_contract.py -q → green: exposure_dataset_ids with
mixed units is 422 naming both units, scope leaves scope_total equal to the clipped members,
a scope of two admin_boundary_ids of one level and one country counts their union once,
mixed levels, mixed countries and an unknown id are each 422 with the reason of §6, the
one-element alias admin_boundary_id gives the same scope_total as the one-element list,
/compare accepts the /impact body with a (scenario, horizon_year) cell, /matrix with
haz_type and cells (baseline with horizon_year: null plus ssp2-4.5 at 2050) and no
hazard_dataset_id resolves two cells (not four), a GRC + CYP
exposure set with no covering dataset is 422 naming both ISO3s, and an ssp2-4.5@2050 earthquake
cell resolves to the baseline group with hazard_conditioning = "not_climate_driven".
- pytest tests/worker/test_exposure_growth.py -q → green: value, deductible and cover scaled by
1.02^(2050 − 2018) for a dataset with properties.reference_year = 2018, exponent 0 for a
Today cell, run year used and recorded when no year is stored.
- pytest tests/test_core/test_vulnerability_resolution.py -q → green: buildings + USD resolves the
default, population answers 422 with details.exposure_type = "population", an explicit
impact_function_id wins.
- pytest tests/test_core/test_result_cache_key.py -q → green: the key is byte-identical to today's
with the new fields absent, and differs when any one is set. Two orders of the same
admin_boundary_ids share one key, and so do the one-element alias and the one-element list.
- Stack: POST /v1/compute/cost-benefit with a grown or adapted impact_result_id → 422 whose
details.reason starts "appraise from an unadapted, ungrown present run".
- Stack: GET /v1/results/{id}/per-exposure after a clipped run returns only rows inside the clip
(every point ST_Within the boundary), and /tiles/results/{id}/11/…?layer=assets draws no
point outside it.
- grep -n "post-cache multiplier" src/climate_lama/core/result_cache_service.py → no match.
- The Python SDK's ImpactRequest carries the five fields and sdk-smoke runs one clipped, grown
request.
EX-21 and EX-22 (saved work, backbone)
- pytest tests/test_api/test_scenarios_contract.py -q → green: a Scenario saved with the run
keys of §6 reads back with its inputs unchanged, with no unknown_input_key warning for those
keys, and with no missing_hazard_dataset warning when it names haz_type and an exposure
selector.
EX-18 (default exposure layer, backbone)
- pytest tests/test_api/test_exposure_datasets.py -q → green: with an older USD buildings
dataset, a newer USD buildings dataset and a newest m3 dataset in one country,
GET /v1/exposure-datasets?country=<ISO3> returns the newer USD buildings one as
default_dataset_id. The key does not change with search, exposure_type, limit=1 or
offset=1. It is null with no country parameter and null for a country with no dataset. A
superseded dataset is never the default.
- pytest tests/test_api/test_exposures.py -q → green: a CSV upload and an XLSX upload each record
bbox as the envelope of their points, and the uploaded dataset then lists under its derived
country.
- pytest tests/test_core/test_assistant_tools.py -k find_exposure_datasets -q → green: the row
the tool flags as default is the default_dataset_id the list route returns for that country.
- The Python SDK's exposure dataset list carries the optional default_dataset_id.
EX-19 (hazard options, backbone)
- For a GRC + CYP exposure set whose river-flood datasets are one per country, whose earthquake
dataset covers both and which holds no drought dataset, the read route returns state
no_single_dataset for river flood, covered for earthquake and not_covered for drought.
reason is null for covered and equals the details.reason of the compute refusal for the
other two. The test calls the core function, the read route and one refused /impact request
and asserts they agree.
- For an m3 selector the read route returns money: false with money_reason equal to the
details.reason of the refused /impact request, and list_hazard_options returns the same pair
(#1673 item 17).
- pytest tests/test_core/test_assistant_tools.py -k list_hazard_options -q → green: for that
same set the tool returns the three states and their reasons and is not a tool error. A
mixed-unit set is still a tool error that names both units.
EX-20 (a run in flight, backbone)
- pytest tests/test_api/test_compute_matrix.py -q → green: the batch list row and the batch
detail each carry created_by_user_id, equal to the submitter.
- pytest tests/test_api/test_jobs_list.py -q → green: a job row carries created_by_user_id
from its params, GET /v1/jobs?status=pending&status=running returns the jobs of both statuses
and no other, and a single status answers as today.
- Stop, in W6 with the cancel route (#1654) and the cancelled job state (#1639): for a batch of
four cells with one completed, one running and two pending, one POST /v1/jobs/{id}/cancel per
pending cell ends those two cancelled. The running cell completes and keeps its result, the
completed cell is untouched, and GET /v1/compute/impact/matrix/{batch_id} still answers 200
with all four cells.
EX-16 (dock), with EX-18 to EX-23 and the amended EX-3, EX-9, EX-10, EX-11 and EX-14
- Playwright smoke: a fresh /explore shows two numbered steps, 1 Scope and 2 Run, with no Layers
tab and no Analysis tab. Scope opens on "What do you want to assess?" with the door "A place"
open. Picking Greece fills in its default exposure layer with no further click, and the layer is
the default_dataset_id of the list response. Narrowing to two
regions shows two chips, each with a remove button, and the address carries
clip=admin:<id>,<id>. Then river flood, SSP2-4.5 × 2050 and Today, growth 0.02, one measure
through "Add measures", and pressing "Run 2 cells" sends one /matrix POST whose body holds
exposure_dataset_ids, scope with two admin_boundary_ids, exposure_growth_rate,
measure_ids, haz_type and cells with exactly two entries, and no hazard_dataset_id.
- Reloading /explore?exposure=datasets:<id>&clip=admin:<id>,<id>&hazard=river_flood&cells=ssp2-4.5@2050,baseline@today&growth=0.02&measures=<id>
restores every control, the two unit chips included.
- Choosing Whole country after drawing an area removes clip from the address and the rectangle
from the map. Changing the country chips clears every narrowing.
- With Scope open the map draws no hazard surface, no result tier, no caption and no horizon
player, and the legend reads the exposed value in the exposure's unit. Opening Run brings back
what was shown.
- The map's control group holds a Layers button. Its popover holds the hazard surface, the return
period, the opacity, results, the scope's assets, event footprints and "Add a layer from Data".
- Vitest: the default layer of a country is read from default_dataset_id. With the key set to
the third row of the fixture the card shows that row, and no client code ranks the rows.
- Vitest: with two countries on and the read route answering no_single_dataset for river flood,
Scope lists "Hazards for computable_count above zero for river flood in both countries, and river
flood stays off, which proves the picker reads state and not coverage counts.
- The hazard picker is one row whose list has the groups "Computable", "Score only" and "Not
available here", with a reason beside each hazard that cannot be picked.
- In the matrix a tick changes cells and never the viewed cell. A click on the body of a
computed cell changes the viewed cell and never cells.
- The damage curve is a select whose first option is the resolved default, with a preview under
it, and the form holds no control named "Advanced".
- Measures show as chips. "Add measures" lists only the measures of the run's hazard, and "Use a
saved plan" loads the measures of a saved plan of that hazard.
- The caption reads "Showing". Its menu lists computed cells only, picking one leaves cells
unchanged, and with the dock collapsed the caption carries "Edit run". No hazard, scenario, year
or engine chip menu exists over the map.
- The strip's third label reads "Expected annual impact", and each of the five figures opens "How
this is made" with the What, How and Use lines of §7.
- A run in flight: after "Run 4 cells" the address carries batch=<id>. Leaving for Data and
coming back shows the progress grid and sends no second /matrix POST. The top bar pill reads
"1 running", lists under "Yours" only the runs whose created_by_user_id is the viewer, and
offers Open. Its poll is one GET /v1/jobs?status=pending&status=running. A finished run raises
a toast with Open and an item in the bell. No progress bar is drawn, and in W2 no Stop control
is shown.
- Stop, in W6 with ui#844: "Stop" beside the running Run row sends one
POST /v1/jobs/{id}/cancel per cell that has not started, the running cell finishes, and the
completed cells keep their results.
- Saving a run of four cells with one failed stores the batch id and three cells, and the toast
reads "Saved 3 of 4 cells (1 failed)". Save is off with its reason until one cell is completed.
- "Open in Explore" on that saved item restores exposure, clip, hazard, cells, growth,
measures, impf, engine and batch.
- Compare: Save stores a comparison with its two sides, the copied address carries a and b,
and a side that is running cannot be started twice.
- Choosing earthquake shows "Hazard same as today" on the 2050 cell and still runs.
- A persons portfolio shows the score-only reason, no damage curve select and no Run button.
- Vitest: the registry dispatches set_scope, set_analysis and compute_cell with each new
argument, a clip of several units included, and the manifest lists them.
- Parity screenshot of the dock beside the prototype snapshot (version 1791210282-291b) at
1440×900 with the snapshot's defaults, every difference listed (guardrail 1).
EX-14 (map): in the phase doc's row, as amended by the Scope rule above.
LK-13: in the phase doc's row.
AS-2 and AS-4: in the phase doc's rows, plus the two sequence tests of §10. In the place
sequence the dataset passed to set_scope is the row find_exposure_datasets flagged as default.
13. Out of scope, and Phase 18¶
- Spatial exposure growth (new locations in hazard zones). Phase 18 stub.
- Per-asset vulnerability binding by occupancy or construction (D21). Phase 18 stub.
- Vulnerability curves for population and built-volume exposures. Phase 18 stub. Until then those exposures are score-only (K19).
- Stochastic event sets and AEP (D21).
- A multi-hazard aggregate figure.
- A monetary damage line in Lookup (D18, Edit home).
- An exposure
reference_yearcolumn. The JSONB property suffices this phase. - A per-country batch for a hazard with one dataset per country, where one press of Run would start one run per country (K41). Phase 18 stub. Until then the hazard is off with its reason and "Run one country".
- A shortlist of measures pinned in Adapt for the run form (K40). Not built. The prototype's pin control is not a target.
- An "Advanced" override for the damage curve and a row of four menu chips over the map. Both were replaced on 2026-10-05 (K47, K37) and are not built.
Reassess record¶
2026-10-02, one conceptual refuter (Fable, read-only, 13 tool calls) over seven claims. Exposure first and the non-climate cell were confirmed. Corrected and adopted above: the two-stage framing (Oasis implements Stage A only), vulnerability before measures, growth applied before the insurance transform with deductible and cover scaled and the dataset's recorded reference year used, cost-benefit over an ungrown, unadapted present base, the business-value claim narrowed to the showcase steps the run model serves (Moment 2 steps 1 to 7, Moment 3 steps 1 to 6, Moment 4 step 9) with engine comparison on future cells through EX-11, the loss-curve basis label, PML inside the dataset's return-period range, and protection standards mapped onto bands (AD-2). Two misses were folded in: growth on the impact request itself, and the engine specification's inconsistent curve below the smallest modelled return period.
2026-10-05, amended from the fine-tune record (phase-17b-finetune.md). The owner compared every open choice on the rebuilt prototype and decided all of them, so the design described on 2026-10-02 no longer matched the target. A fast reassess of this amendment ran the same day, before any issue was touched, and its corrections are listed after the sections. What changed and why:
- §3 and §3.1: Scope asks "What do you want to assess?" through two doors, and the exposure layer of a country fills in by a default rule, because the earlier Scope read as insurer-only with no visible way to run a country (K38, EX-18, BC-2).
- §3.2: narrowing choices exclude each other and a clip may list several units of one level and one country (K50, EX-23, BC-1).
- §3.3: the hazard picker is one row with a grouped list, and several countries get one rule for what each hazard can do (K39, K41, EX-19, BC-3). The sentence about an Advanced control that pins a hazard dataset went, because the decided run form has none.
- §3.5: the damage curve is a visible select with a preview, no "Advanced" override (K47).
- §3.7: measures are chips with "Add measures" and "Use a saved plan" (K40).
- §4: the saved-work list is History (K43), the basis label says portfolio (K49), and a saved run carries its batch (BC-5).
- §5: the Target column follows the points above.
- §6:
scopetakesadmin_boundary_idswith three refusals and one stored form for the result, the cache key and the provenance (BC-1). New paragraphs give the code checks of 2026-10-05 and their rules for hazard options (BC-3), a run in flight (BC-6, BC-7) and saved work (BC-5). - §7: the dock is two numbered steps with Layers on the map (K36, K48, reversing K13's three tabs), one caption replaces the four chips (K37, reversing EX-9), a tick selects and a click shows (K51), every strip figure explains itself (K52), and the section now covers a run in flight (EX-20, K42), saving several cells (EX-22), reopening saved work (EX-21), Compare (FT-21) and the map with Scope open (FT-3).
- §8: the Lookup pin's "Open in Explore" and the code check behind it (LK-14, BC-8).
- §9: Adapt's landing page (AD-17).
- §10: the assistant runs a place through
find_exposure_datasets(AS-4, BC-4). - §11: "portfolio" replaces "book" in the copy (K49), and the lines the fine-tune adds are listed.
- §12: the dock checks prove the decided design instead of "Scope opens on What is at risk".
- §13: the per-country batch, the pinned shortlist, the "Advanced" override and the chip menus.
The fast reassess of 2026-10-05 confirmed every code fact cited in the amendment and corrected four contract rules:
- The default exposure layer is decided once, in the backbone, by
default_exposure_dataset, and served as the envelope keydefault_dataset_id. The first draft had the UI apply the rule and the assistant tool apply it again. The upload path also has to recordbbox, because it writes none today and a dataset without one lists under no country (§3.1, §6, §10). - The reads behind the pill are named:
created_by_user_idon the batch list row, the batch detail and the job row, and a repeatedstatusparameter onGET /v1/jobs, in one new backbone issue of W2. The first draft left the keys to the issue (§6). - Hazard options carry one enum,
state, with the valuescovered,no_single_datasetandnot_covered, and areason.list_hazard_optionsreturns them for a set of several countries instead of refusing the call, and with more than one country on the picker readsstateand never coverage counts (§3.3, §6, §10). - Stop keeps its meaning and its wave: one cancel per cell that has not started, in W6 with the cancel route, the cancelled job state and ui#844. The W2 work of EX-20 is everything else (§6, §7).
§12 follows the four corrections.
2026-10-07, amended from the delta split of the fine-tune (the phase doc's Split record). The 33
issues it filed named the paths and keys this document had left to them, and the decisions they
settled are K55 of the phase doc. §4 names the result's design_return_period and
value_weighted_average_band (#1595) and the stored results read GET /v1/results with its
indicator_score (#1664, #1665). §6 names the hazard options route
GET /v1/compute/hazard-options with scope_total (#1661), impact_workers, the per-cell
hazard_dataset_id and cached and the reuse of stored results by matrix cells (#1662), the saved
keys compare, live_event_id and event_key with the saved cells form and the live_event_id
and mine filters (#1663), the stored results read (#1664) and uploads as jobs with step, the
cancel, country, reference_year, supersedes_dataset_id and portfolio_id (#1667, #1670). The
route and the saved keys replace the two sentences that left their names to the issues. §8 names
location.containing_units (#1668).