Skip to content

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 yet" with "Add one". Layers of different units across the chosen countries keep the mixed-set refusal of §6. An uploaded exposure lists under a country only when the upload recorded its extent, which §6 makes the upload path do.

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 together", one line per hazard type: "One dataset covers both" 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 dataset covers . One run never stitches per-country hazard files.", and "Run one country", a menu that drops the scope to one of the countries. A hazard in the state 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 · ", and the other options are the catalogue's other curves for that pair. Under the select a small preview draws the chosen curve as damage share against intensity, with the default dashed beside it when another curve is chosen, and a line gives the curve's source and version. No "Advanced" override exists. Two refusals name their cause (PF-4, K19): an exposure type with no curve in the catalogue (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_total and rows_computed. The result's stored admin units, ranked by EAI, through GET /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} serves checksum, the SHA-256 of the result's stored eai_exp, impact_per_event and freq_curve in the canonical byte form of docs/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 serves design_return_period, the return period the compute banded at (100 for river flood and 476 for earthquake under the default settings), and value_weighted_average_band, Σ band × exposed_value over Σ exposed_value of the result's band_distribution, computed by the function behind the rollup's value_weighted.combined_score. Each is null on a result written before the change, and the average band is null wherever band_distribution is 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 carries indicator_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 (inputs already typed for measure_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). The scenario_id replay 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_ids empty, or sent with portfolio_id or exposure_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_level and iso3 that GET /v1/admin-boundaries serves (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).
  • cells with 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 from compute_batches.created_by_user_id.
  • The job row gains created_by_user_id, read from the user_id of the job's params. A run of one cell has no batch, so the pill needs it there.
  • GET /v1/jobs accepts a repeated status parameter (?status=pending&status=running), so one call returns every job in flight. One status keeps 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_id or exposure_dataset_ids, scope, haz_type, cells, measure_ids, impact_function_id, engine, the growth rate as growth_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 and GET /v1/scenarios/{id} returns inputs as 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 because inputs is JSONB. missing_hazard_dataset is raised only when a blob names neither hazard_dataset_id nor haz_type with an exposure selector, because this contract resolves the dataset from those two. The scenario_id replay of POST /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_id is declared (schemas/scenario.py:64). No field is added.
  • Which cells completed. No key holds it and none is added. The saved cells lists 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 through batch_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, because result_id holds one.
  • A comparison's two sides. comparison_id is declared (schemas/scenario.py:65) and names an engine comparison group, which GET /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_id cannot 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, eai the asset's entry of that result's eai_exp and band its entry of band_exp (null for 0, and for an archive with no band_exp). An exposure id that is not one of the selector's rows inside its clip is 404 E_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 a result_admin_cells row for the unit, with that row's eai and points. An unknown boundary is 404 E_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_id the 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 " · clipped to · locations" for a portfolio. These are formats, and their figures are illustrative. More than two units shorten to the form "Attica, Thessaly + 2 more". The top bar's scope chip shows the same line and offers places and portfolios. The primary button "Next: choose the hazard" opens Run and is off until an exposure resolves.

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:X
  • scope=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 absent horizon → 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:

  1. Hazard. One row that opens the grouped list (K39, §3.3), coverage-gated.
  2. 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".
  3. Damage curve. The select with its preview (K47, §3.5).
  4. Exposure growth. A number input in % per year, with the hint "Uniform growth from (), existing locations only. A Today cell is never grown."
  5. Adaptation measures. Chips, "Add measures" and "Use a saved plan" (K40, §3.7).
  6. Engine, with Compare engines.
  7. 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).

  1. 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.
  2. 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.
  3. 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.
  4. The UI forms the headline as the worst banded hazard (lookupSummary.ts), the four hazard groups, and "n hazards not covered" from coverage (D6).
  5. 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, then 1 − Π(1 − p_y) over the years, with the scenario's trajectory between the anchors.
  6. 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 and tell me its expected annual impact".

  1. find_portfolios("<portfolio>") (new query tool) → the id.
  2. open_view explore, then set_scope {exposure: "portfolio:<id>"}.
  3. list_hazard_options({portfolio_id}) (new) → river flood is computable, its Today cell exists.
  4. set_analysis {hazard: river_flood, cells: ["baseline@today"]}.
  5. 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.
  6. get_job(job_id) (new) → result_id, then get_result_summary(result_id) → the figure and its value_unit (AS-5, #1650): the unit GET /v1/results/{id} serves, null where the result's rows mix units.
  7. The reply quotes the served figure only (the unsourced-figure guard, #1495), in the served value_unit, and names no unit when it is null.

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.

  1. 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 calls default_exposure_dataset (§6) and flags the row it returns, the same row the list route names in default_dataset_id, so the assistant and the door "A place" can never pick two different defaults. region is 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.
  2. open_view explore.
  3. set_scope {exposure: "datasets:<the default id>", clip: ""}. set_scope takes exposure in the datasets: form and a clip that 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.
  4. list_hazard_options({exposure_dataset_ids: [<id>]}) → river flood reads state: covered and its Today cell exists.
  5. set_analysis {hazard: river_flood, cells: ["baseline@today"]}.
  6. compute_cell (Run or Skip in the trace), awaited as in the portfolio sequence.
  7. get_job(job_id) → result_id.
  8. get_result_summary(result_id) → the figure and its value_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 together" with "No single dataset covers both" on river flood, and in Run river flood is off with its sentence and "Run one country". The fixture's coverage reports 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_year column. 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: scope takes admin_boundary_ids with 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 key default_dataset_id. The first draft had the UI apply the rule and the assistant tool apply it again. The upload path also has to record bbox, 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_id on the batch list row, the batch detail and the job row, and a repeated status parameter on GET /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 values covered, no_single_dataset and not_covered, and a reason. list_hazard_options returns them for a set of several countries instead of refusing the call, and with more than one country on the picker reads state and 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).