Skip to content

Phase 17b · The domain model

Status: defined 2026-10-02, the reference beside phase-17b-run-model.md for every issue of Phase 17b that touches a figure, a dataset or a flow (phase-17b-walk-corrections.md, guardrail 11). The run model defines how a scenario is run. This document defines what every other surface computes, why it is scientifically sound, where it stops, and which defects the 2026-10-02 audit found in the data the platform holds and in the figures it shows. It was verified by a /reassess fast pass of two refuters (one on scientific and engine grounds, one on business and product grounds) and a completeness critic (see Reassess record). Nothing here is implemented yet. The rows that implement it are DT-1 to DT-6, PF-8 to PF-11, AD-11 to AD-16, EV-10 to EV-18, RP-1, EX-17 and EN-8 in the phase doc.

Sources read: the engine spec climate-lama-engine/docs/CLIMATE_LAMA_ENGINE.md and its cost_benefit.py, measure.py, CLIMADA.md §6 to §7, the Oasis evaluation on disk (OED location and account files, PiWind keys), the market scan scripts/market_scan/data.js, the backbone models, services and ADRs at 02d3f092, the UI at d2b8e2b5 and the prototype snapshot.

1. The data we hold

1.1 Hazard datasets: one source, one event axis

A hazard dataset is one source (ADR-067). Its events are either return-period-shaped (rp, exceedance 1/rp, and a marginal weight that is the band width 1/rp_i − 1/rp_(i+1), last band 1/rp_max, core/ingest/base.py:132-143) or scenario × year-shaped (indicator by structure, ADR-065). The engine sums losses by the marginal weights, which is the exact trapezoidal integral of the loss-exceedance curve, and recovers the curve by cumulating them (engine §2, §4). A frequency-less dataset is refused at compute and still bands in Lookup (ADR-069). Computability per (country, hazard) is modelled | screening | indicator | none from the same classify_eai the compute gate uses (core/catalog_coverage.py:94-153), and Lookup is not gated by it.

Defects and rules:

  • frequency_type says "occurrence" where "marginal" is stored (storm, tropical cyclone and wildfire ingestors set a static label while every path stores and reads band widths). The column stays, because the engine contract requires the tag (spec §4). The return-period commit writes "marginal" whenever _compute_marginal_freqs drives the weights, the adapter's default becomes "marginal", live snapshots (one event, weight 1) keep "occurrence", and a backfill sets marginal on every row with a non-empty return_periods (DT-1).
  • No hazard ingest writes bbox, so precedence's locality falls back to the region label and its extent term is None (core/dataset_precedence.py:207-240). The commit step writes bbox from the footprint envelope and a migration backfills it from footprint (DT-2). Precedence never reads footprint, so nothing is double-counted.
  • A single unlabelled field (ADR-068 shape, the EWDS historical FWI) never bands in Lookup (core/lookup_service.py:1003-1007, 1248-1272). Lookup treats a one-field unlabelled dataset as its only slice and bands it (DT-3).
  • Drought is an enum with no pack, curve or scheme. Coverage reports none and no surface offers it (hazard grids, Create watch) until a pack exists.
  • What a hazard upload declares (DT-7, #1667, #1670, 2026-10-07). POST /v1/hazards/upload takes supersedes_dataset_id for a new version (#1667), event_date for one observed event sent with single_event, and netcdf_variable for a NetCDF file in its one declared layout: one variable (default intensity) on (return_period, lat, lon) or (lat, lon), on a regular EPSG:4326 grid (#1670). An event outline as GeoJSON goes through POST /v1/hazards/footprints, which stores a single-event dataset with the outline as footprint and no intensity. The hazard read serves supersedes_dataset_id, and outline_only, derived (true when the dataset stores neither npz_path nor cog_path) and never a column. Compute refuses an outline-only dataset.

1.2 Exposure datasets: points with a value, a type and a unit

An exposure row is a point with value, value_unit, exposure_type (buildings, population, infrastructure) and free properties. A dataset carries attribution, identity (ADR-048), bbox and a derived country (#1166). A streamed source ingest records bbox. An upload does not today (core/exposure_service.py:1045-1060), so an uploaded dataset has no derived country and lists under none. EX-18 makes the upload record bbox as the envelope of its points, and fills it in for the uploads that already exist (run model § 6). A portfolio is a selection over datasets (all | bbox | rows, ADR-080). Compared with Oasis OED (OccupancyCode, ConstructionCode, BuildingTIV, ContentsTIV, LocDed, LocLimit) and CLIMADA Exposures (impf_<HAZ>, deductible, cover, category_id), the product models exposure at dataset level: one curve and one set of insurance terms per run, uniform terms before growth, scaled per dataset by the growth factor (#1593). That is the documented simplification of D18 and D21, stated in every result's provenance, not an error.

Rules: the CLIMADA HDF5 upload drops deductible, cover and category_id silently (core/ingest/climada_hdf5.py:120-121, 354-358). The ingest summary lists dropped columns and the Data drawer shows them (DT-4). The upload template never asks for occupancy, construction or year built, which no run consumes, until per-asset binding lands (Phase 18).

The exposure upload runs as a job (DT-7, PF-12, #1667, 2026-10-07, run model § 6, "Uploads run as jobs"). Its form declares country (ISO3, checked against the country its points derive), reference_year (stamped into each row's properties.reference_year, never a column), supersedes_dataset_id for a new version, and portfolio_id, portfolio_name and portfolio_owner when it imports a portfolio. The exposure dataset read and its list rows serve portfolio_id and supersedes_dataset_id, so a dataset written by a portfolio import names that portfolio and a new version names the dataset it replaces.

Boundaries (DT-7, #1670, 2026-10-07): a boundary file is held for review through POST /v1/admin-boundaries/uploads as a pending_ingests row that names the asking organisation and person, and nothing loads until a platform admin approves it through the existing approval route. "Regions", "Regional units" and "Municipalities" load at levels 1, 2 and 3, and another set of areas loads at level 3 under the source user_upload. GET /v1/admin-boundaries serves level 3, because revision 0116 of #1667 widens ck_admin_level_range to levels 0 to 3.

1.3 Vulnerability curves and score schemes

One default curve per (org, hazard type), 22 seeded curves, all for buildings (core/impact_function_seeder.py:564-844). The run model resolves on (haz_type, exposure_type) and refuses curve-less types and non-money units (K19). Score schemes are versioned, org-less, ten half-open bands per (hazard, metric, unit) (ADR-043, ADR-073), banding the intensity at the design return period. Heatwave and wildfire-FWI are indicator perils: a score, never a money loss.

Rules: the exposure-type pairing becomes an exposure_type column on impact_functions (NOT NULL DEFAULT 'buildings'), and the one-default partial unique index moves to (org_id, haz_type, exposure_type) WHERE is_default, so a population curve can be seeded later without code (DT-5). Heatwave stays score-only everywhere, with no money run and no cost-benefit. A curve is uploaded through POST /v1/impact-functions with exposure_type (default buildings) and is_default (default false), and a second default for the same (haz_type, exposure_type) answers 409 from DT-5's index (DT-7, #1670, 2026-10-07), its code the literal CONFLICT every other 409 of this API carries, since the registry holds no E_CONFLICT.

1.4 Result cache

The cache key already holds the exposure sha, the impact-function version and a non-default engine (core/result_cache_service.py:166-179). Two gaps serve wrong figures today (EX-17, W0):

  • deductible and cover are not in the key (compute.py:293-294, 653-677), so an insured run and a ground-up run over the same inputs return each other's result as a 200 cache hit. Both join the key, stringified, only when not null, the engine_name pattern.
  • A portfolio-addressed run is keyed on portfolio_id alone (result_cache_service.py:180-181) while membership is mutable (api/v1/portfolios.py:252-345) and the only invalidation purges orphans. The key gains a membership digest: 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. The same digest covers exposure_dataset_ids.

2. Lookup

As run model §8 and K15: a hazard screening of one point, the right shape for the citizen's address journey (First Street, ThinkHazard). Additions: DT-3 for unlabelled fields, LK-13 for the "How this score is made" block.

3. Portfolio rollup

What GET /v1/risk/portfolios/{id} computes today (core/rollup_service.py): up to 100 members snapped to H3 r8 cells written by runs, each banded per hazard, the member's worst band taken, a headline = max band across hazards, per hazard the band of the value-weighted mean intensity, and value_weighted.combined_score = the value-weighted mean of the members' ordinal bands. EAD on cards comes from saved runs through total_ead.

Model and rules:

  • Headline figures. Portfolio grades are a market norm (A to F ratings at portfolio level, data.js:272, pattern 9) and Moment 2 step 3 is a live walk, so the value-weighted average band stays and is labelled everywhere "value-weighted average band (ordinal, screening)". Beside it the rollup returns value_weighted.band_distribution = [{band, exposed_value, share}] over the ten bands and value_weighted.share_high_or_severe, computed over the same banded members and weights. The card's headline is the worst band, then "X % of value in high or severe". This is the backbone half of PF-7's band distribution bar and band donut (PF-9).
  • Per-hazard figure. The band of the value-weighted mean intensity is a screening figure labelled "typical intensity across the portfolio", with the worst band per hazard beside it (PF-9).
  • EAD is per hazard, from portfolio-scoped runs. total_ead adds a dataset's whole EAD once per member row (db/repositories/portfolio_repository.py:580, pinned by tests/test_api/test_portfolios.py:461-502: EAD 1 000 over a two-row dataset reports 2 000) and takes the latest result whatever its hazard (:110-128). Rule: EAD per (portfolio, haz_type) = the latest impact result whose exposure selector is this portfolio, for that hazard, scenario and horizon. Only a dataset attached with selection all may use a dataset-level result, weighted once per dataset, never per row. A bbox or rows portfolio with no portfolio-scoped result reads "not computed for this portfolio". The card shows one hazard's EAD with its hazard label, never a cross-hazard sum (the engine has no aggregator). The test asserts 1 000 for the two-row case (PF-8, W0).
  • Removing an asset narrows the selection, never the layer (PF-12, #1688). POST /v1/portfolios/{id}/members/remove records each removed id on the attachment that covers it: excluded_row_ids on an all or bbox selection, which keeps its kind, or a shorter row_ids on a rows one. No exposure row is deleted. Every membership reader honours the exclusion, and an all attachment with one no longer stands in a dataset-level EAD (the rule above).
  • One unit or none. exposed_value and the value-weighted figures are returned only when every member shares one unit, money or not. A single non-money unit prints as text through PF-1's formatter ("16.8M m3"). Mixed units → exposed_value = null, value_weighted.unit = null with reason "mixed units", band figures weighted by member count, the card shows the member count and "mixed units". PF-4 still refuses a money loss for a non-money unit (PF-10). As built (#1579): the gate is core/value_units.py::shared_value_unit over every loaded member. The rollup's value_weighted block carries exposed_value, unit, unit_is_money (true for an ISO 4217 code, false for m3 or persons, null with a null unit), reason ("mixed units" or null) and weighting: member_value_times_membership_weight under one unit, member_count under mixed units, where each banded member weighs 1 in combined_score and in each hazard's aggregate_value (provenance aggregation: "member_count_mean"). Each /v1/portfolios/summary row carries unit_count, the distinct units its members hold (0 for an empty portfolio), and answers exposed_value: null beside value_unit: null when unit_count > 1. Such rows rank last under sort=exposed_value. No FX or unit conversion is made.
  • Each surface names its unit (PF-3, PF-2, #1578). A GET /v1/portfolios list row carries value_unit (an ISO 4217 code, m3 or persons: the covered members' shared unit, else the portfolio's, else null) and unit_is_money (core/value_units.py::is_money_unit, null with a null unit). /v1/portfolios/summary rows carry unit_is_money beside their value_unit. /aggregate keeps its legacy key currency, which holds the list row's value_unit and is null, never "USD", when the members mix units. Nothing is converted.
  • The 100-member cap is disclosed: the response already knows scored, and the card reads "scored on the N largest members" whenever scored < members (PF-10).
  • Indicator perils have no run-written cells, so the card reads "n hazards not scored for portfolios" from coverage, the same contract as Lookup's "not covered" (PF-10).
  • Scheme per member cell (PF-11, verified by #1581, 2026-10-09). The rollup picks one scheme per peril, for wildfire the brightness-temperature (K) scheme by the metric-name tie-break (_active_schemes in core/rollup_service.py). No FWI value is ever banded on that ladder, because the unit gate sits at write. Every surface writer files a cell metric only through rollup_metric_key_for_run (core/scoring.py), which returns no key when the dataset's intensity_unit differs from the selected scheme's unit. FWI wildfire and heatwave datasets are indicator datasets that no run computes, so they write no cells (ADR-075), and a portfolio over FWI data reads wildfire unavailable. Per-dataset scheme selection and per-unit hazard rows are not built, since no FWI cell can feed them. tests/test_core/test_rollup_scheme_gate.py pins this at the rollup rung, through the real writer path. Reverse when an FWI or heatwave damage curve makes those datasets computable.
  • A typical score per indicator hazard (PF-11, #1665, 2026-10-07). Each hazard entry of GET /v1/risk/portfolios/{id} carries indicator_score, {band, label, metric_unit, scheme_ref, hazard_dataset_id, members_sampled, in_portfolio_band} with in_portfolio_band always false. It is the value-weighted mean of the capped members' bands, sampled from the indicator dataset's own raster off the event loop and banded through the scheme of its intensity_unit (FWI, days), with member count weighting for a mixed-unit portfolio (K25). It stays outside the headline, the band distribution and the share in high or severe, so the synopsis lists it as "indicator, not in the portfolio band". It is null for a run-banded hazard and where no indicator dataset covers the members for the requested scenario and horizon. The stored results read GET /v1/results serves the same figure for an exposure selector through the same sampling function (run model § 6, #1664).
  • What a portfolio card names (PF-7, #1666, 2026-10-07). The rows of GET /v1/portfolios and GET /v1/portfolios/{id} carry owner, a free-text team label of at most 120 characters, category (property, hotels, public or energy, optional) and geocoded_precise_share (0 to 1, null with no member), and the list envelope serves the organisation's distinct owners. GET /v1/portfolios/{id}/thumbnail serves {bbox, countries, weight, value_unit, cells: [{lon, lat, value}]}, the members' value binned on a 16 × 16 grid over the portfolio's bbox (member counts with weight: "member_count" for a mixed-unit portfolio), which the card draws as its small map. The spread's band is read from the portfolio rollup's member bands (the stored cells at the largest members), never stored per member (decision of 2026-10-15). GET /v1/portfolios/{id}/spread?haz_type=&scenario=&horizon_year= serves {admin_level, weight, value_unit, areas: [{boundary_id, name, exposed_value, member_count, band}]}, the five largest areas, for "Geographic spread".
  • Where a portfolio came from (PF-12, DT-7, #1667, 2026-10-07). Portfolio rows serve source (upload, exposure_layers or null), exposure_layers ([{dataset_id, name}]) and uploaded_dataset_id, so a card reads "Built from exposure layer: " or links to the file it was uploaded from, and that file's row in Data links back (§1.2).

Showcase: Moment 2 step 3 reads "headline band, per-hazard chips, band distribution of value with the share in high or severe, value-weighted average band (screening)" and step 6 reads "(exposure count, EAD per hazard)" in the PR that lands PF-8 and PF-9.

4. Adaptation measures

4.1 The entity and its catalogue

A measure (ADR-019) is a stored, attributed transform: a hazard-intensity factor (hazard_inten_imp → engine haz_inten_a), damage-curve factors (mdd_impact_a/b, paa_impact_a/b), a frequency cutoff, a cost in a currency, a lifetime, a region label that nothing reads. The seeded catalogue holds two flood measures with cost = NULL by design: a published unit cost is country-, currency- and year-specific (core/measure_seeder.py:12-16, 104). That honesty stays. Additions through AD-4's migration (AD-11, AD-15):

  • cost_basis (lump_sum | per_building | per_km | per_ha) and unit_count, so total cost = unit cost × count is explicit and resolved backbone-side before _build_measure. The engine takes one scalar cost in the exposure's unit (engine measure.py:51, 78-79).
  • cost_unit must equal the run's value_unit, else 422 "measure cost unit differs from the exposure unit ". The adapter stops defaulting it to USD (engine cost_benefit.py:136, 149).
  • hazard_inten_shift → engine haz_inten_b (hard-coded 0 today, engine_adapter.py:371), labelled "Raise the asset or floor by h" with the copy "depth above the raised floor = max(depth − h, 0)". A levee or wall is never a depth subtraction: when it holds the depth is zero, when overtopped it is the full depth, so barriers use the protection standard below.
  • applies_to as JSONB with kinds scope (the whole run scope, the default and the prototype's "buildings"), admin ({admin_boundary_id}) and assets ({asset_ids}), consumed by 4.2; effect, cobenefits and the plan icon from AD-4. One W4 migration carries all of them. rp_protect (AD-2) lands earlier, in W0's migration, with the seeded flood protections set to 100.

4.2 How a measure applies

Measure.apply(hazard, impfset) returns a new hazard and curve set and acts on every exposure of the run (engine §6). Rules:

  • Protection standard (AD-2, amended): a return period RP_protect is translated by selecting the event rows with rp <= RP_protect and removing them, never by a frequency threshold. Marginal band widths are not monotone in return period for irregular lists (RPs 10, 11, 100 give widths 0.0091, 0.0809, 0.0100), so a cutoff on weights can drop the wrong bands. Cards read "prevents floods up to 1-in-100 years".
  • Applies to a subset (AD-14): applies_to is the whole run scope (default) or an exposure mask (an admin boundary id or asset ids). The worker runs ImpactCalc on the masked and unmasked subsets with and without the measure and combines: eai_exp concatenated in point order, aai_agg summed, at_event aligned by event id (events a cutoff removed from the masked half filled with 0) then added, and the loss curve computed from the summed at_event, never as a sum of curves. Exact with and without deductible and cover (insurance applies per point before aggregation, engine §5). The impact run needs no engine change. Cost-benefit does (4.3).
  • Hazard match (AD-5): a measure whose hazard differs from the run's is 422, the worker never overwrites haz_type.
  • Indicator hazards (AD-16): on heatwave or wildfire-FWI a measure appraises as a score delta only when it carries a hazard-side transform (hazard_inten_imp, hazard_inten_shift or a protection standard at the design return period): the band of the transformed intensity at the design return period versus the baseline band. A curve-side-only measure on such a hazard is refused in the Adapt form with "no loss curve for this hazard, this measure cannot be appraised; record it as a qualitative action". Never a BCR. POST /v1/compute/score-delta (#1627) serves it. The transformed intensity is max(hazard_inten_imp × value − hazard_inten_shift, 0), and an rp_protect at or above the design return period reads the lowest band. Per measure the answer names measure_id, haz_type, baseline_band and adapted_band (the scope's "value-weighted average band (ordinal, screening)", K25, to four decimals), baseline_distribution and adapted_distribution (exposed value per band, [{band, exposed_value, share}] from PF-9's one function), and baseline_share_high_or_severe and adapted_share_high_or_severe. A member weighs its row value. Under mixed units (K27) every exposed_value is null, reason is "mixed units" and the shares are by member count. A scope with no member is 422 "nothing to score". A measure masked to an admin area or to named assets (AD-14) is refused, not applied to the whole scope. A portfolio (its largest rollup_max_portfolio_members members) or a set of at most that many rows answers 200. A larger set answers 202 with a score_delta job: the worker runs the same function over every member, stores the answer in jobs.result_payload, and GET /v1/jobs/{id} serves it as the job's result. POST /v1/compute/cost-benefit gives the curve-side-only refusal for an indicator base result.

4.3 How a plan is appraised

Cost-benefit runs over a Today base and a climate-adjusted future hazard (#1482), risk metric aai_agg, flat discount, benefit as a linear ramp between present and future avoided loss (engine cost_benefit.py:42, 76-81), BCR and payback (#1544). Rules:

  • Lifetime (AD-12). lifetime_years is stored and never read. The engine gains benefit_years: int | None and zeroes the per-year benefit for i > benefit_years. n stays horizon_year − present_year, so the ramp slope is untouched. The backbone passes the measure's lifetime and sets future_year to the hazard horizon year when a future cell is given (AD-6), never now + N. Payback is the first year the cumulative per-year benefit reaches the cost, from the same series, exposed as per_year_benefit on the result so payback and NPV share one curve.
  • A plan is one combined run (AD-13). Plan totals today are independent sums (api/v1/results.py:611-613), which over-count overlapping measures. The engine gains combined: bool on calc_cost_benefit: measures apply in list order to one hazard and curve set, one result named "plan" with cost = Σ costs (one cost unit). Order matters once a depth shift exists (shift then factor gives max(1.0 − 0.6, 0) × 0.5 = 0.2, factor then shift gives max(0.5 − 0.6, 0) = 0), so the canonical order is hazard-side shifts, then hazard-side factors, then protection standards, then curve-side factors, unless the user reorders the persisted plan (AD-8). The form says "order matters when a measure lowers depth". Per-measure rows stay for ranking. Residual risk = baseline EAD − the combined plan's avoided loss.
  • Lifetime segments (#1624, decided 2026-10-02). The plan's benefit is summed over one lifetime segment per distinct lifetime L1 < … < Lk (a null lifetime counts as n = horizon_year − present_year). Segment j is one combined run of the measures still alive (lifetime ≥ Lj) and adds npv_of_avoided(…, benefit_years=Lj) − npv_of_avoided(…, benefit_years=L(j−1)) of that run's avoided pair; the first segment adds its whole NPV to L1, year 0 included. The plan's per_year_benefit takes segment j's entries for years L(j−1) < i ≤ Lj. A 50-year and a 5-year measure are two runs, so the short one never cuts the long one's benefit and the plan never ranks below its own 50-year row. No per-measure sums (K29), at most k combined runs.
  • The plan block (#1624). A job of two or more measures stores the combined run as one plan row and GET /v1/results/{job_id}/cost-benefit serves it as plan: npv_benefit, npv_cost, benefit_cost_ratio, payback_years, per_year_benefit, avoided_loss_present, avoided_loss_future (baseline risk − the risk with every measure applied), residual_risk_present, residual_risk_future (baseline risk − the plan's avoided loss), haz_type, unit and cost_unit. The top-level totals are the plan block's. A one-measure job and a job stored before the plan row answer plan: null.
  • The comparison figure (adapt-4, K29). The plan block also serves single_measures_avoided_loss, the sum of the per-measure rows' avoided_loss_future in the plan's unit, for the line "single measures add to , overlap removed". It enters no plan figure.
  • A saved plan (AD-8, amended 2026-10-05 by the fine-tune, FT-7 and FT-14). A saved plan keeps a name, its scope (the exposure and the clip), one hazard, the measures of that hazard in plan order, the cell and the appraisal settings. Its avoided loss is the figure of the plan as one combined run, in the exposure's unit, never a sum of the per-measure rows, and a hazard with no loss curve shows no money figure. Adapt's landing page (AD-17) lists every saved plan with its name, scope, hazard, number of measures, avoided loss and Open, which sets the scope, the hazard and the measures. In Explore's run form "Use a saved plan" offers the plans of the run's hazard and loads their measures into the run, which keeps its own scope (run model § 3.7).
  • The plan entity (#1632, migration 0114). adaptation_plans keeps the compute request's names: name, the selector portfolio_id or exposure_dataset_ids, scope as sent, haz_type, the cell scenario + horizon_year, measure_ids in plan order, custom_order (true once the user reordered the plan, AD-13), the settings discount_rate, value_growth_rate and time_horizon_years, and the server-written link last_appraisal_job_id to the plan's last combined appraisal (#1641 sets it; a client never does, and a change to anything but name clears it). It stores ids and settings, never figures.
  • The list read (AD-17, FT-14, FT-7). GET /v1/adaptation-plans serves each plan with appraisal_status (the linked job's status, null with none), avoided_loss (the job's plan block avoided_loss_future, or a one-measure plan's measure row, null until the job completed) and avoided_loss_unit (that row's unit), read through the link in two statements for any page; ?haz_type= gives "Use a saved plan" the plans of the run's hazard.
  • Presentation (AD-3, extended): the centre's first card ranks the plan's measures by BCR as the ECA cost curve (x = cumulative avoided loss, bar width = the measure's avoided loss, height = BCR) and shows residual risk, labelled per hazard.
  • Engine items (EN-8): benefit_years, combined, an npv_of_avoided(avoided_present, avoided_future, ...) helper lifted out of the closure at cost_benefit.py:105-119 for the masked pieces of 4.2, and §7 of the spec rewritten to the linear ramp the code implements (CLIMATE_LAMA_ENGINE.md:371-389 still documents the point-in-time formula).

Market analogues: CLIMADA and the ECA network's cost-benefit, Climate X's defences toggle (data.js:250), XDI's ranking by avoided value and break-even (:301), FloodAdapt's with and without comparison (:799). What the product deliberately does not do this phase: per-year discount schedules, portfolio risk transfer (risk_transf_attach/cover), entity swaps (exposures_set, hazard_set, imp_fun_map), a return-period risk metric (Phase 18 stub).

5. The live lane and events

5.1 The model

A watch is one unfolding event over one exposure selector with an AOI. A feed (replay, FIRMS NRT, USGS ShakeMap) releases observations that ingest as single-event snapshot datasets (one event, weight 1, frequency_type = "occurrence"). The event loss is Σ value × MDD(i) × PAA(i) with one default curve, stored apart from annual figures (ADR-078), graded screening, never an alert, with the life-safety line (ADR-078 item 6), feeds trusted in-process and replays on their real clock (ADR-081). This is the event-response pattern of the market (Moody's footprints at data.js:42, CatNet near-real-time overlays at :92, GEM/RISE ShakeMap → exposure → loss) and it is honest as long as every surface labels it a deterministic ground-up loss of one observed footprint. The lane is not a forecast: no ensembles, no lead time, no uncertainty (CLIMADA's Forecast module and PAGER's loss bands are Phase 18 at the earliest).

5.2 Rules

  • Event to date (EV-10). FIRMS snapshots hold only each overpass's new detections (ingest/feeds/firms_nrt.py:23-34), so "latest event loss" is one overpass. Each poll also writes a cumulative snapshot, the cell-wise maximum brightness temperature over the watch's observations to date, which is the quantity CLIMADA's WildFire module (Lüthi 2021) feeds the brightness-temperature curve and is monotone non-decreasing. The headline, the Home card and the rail use the cumulative figure. Per-overpass snapshots remain the replay frames.
  • Events inside a watch (EV-11). Snapshots carry an event_key (the USGS event id, the FIRMS watch itself, the replay manifest's event). Timeline, delta and headline operate within one event key. A ShakeMap revision dedupes on (watch, event_key, version), not (watch, valid_at), so revisions are scored (ingest/feeds/usgs_shakemap.py:721-726, db/repositories/live_event_repository.py:567-611).
  • The selector travels (EV-12). Compute always carries the watch's own selector (the UI sends only portfolio_id, EventsMode.tsx:291-296). Deleting a portfolio or exposure dataset that an open watch references is refused with 409 naming the watch, replacing the ON DELETE SET NULL that silently widens a watch to every org exposure (models/live_event.py:44-49, 121-125).
  • Curve gate (EV-13). Create watch offers a money loss only for hazards whose default curve consumes the selector's exposure type (coverage computability). Otherwise the watch is "exposure count only" with auto_compute off and the dialog says so (today heatwave and drought are offered with auto-compute on, CreateWatchDialog.tsx:219, 416).
  • Basis and terms (EV-14). Every surface labels the figure "ground-up loss of one observed footprint, not annual" (today only the server's grade note, EventLossHeadline.tsx:108-113). A watch may carry the same uniform deductible and cover the annual run accepts. With one event of weight 1 the engine's per-point insurance transform yields the insured event loss with no engine change, stored beside the ground-up figure. A watch summary (LiveEventRead, served by GET /v1/live-events and GET /v1/live-events/{id}) carries latest_insured_event_loss, the insured_event_loss of the same newest result latest_event_loss reads, in latest_event_loss_unit, and null when that result has none (#1611, 2026-10-07). The terms are editable through PATCH and apply to computes dispatched after the edit. A stored result keeps the terms it ran with as provenance.terms ({deductible, cover}), so latest_insured_event_loss always reads with the terms of the result it comes from (#1611, 2026-10-10).
  • Updates, never alerts, no subscriptions (SH-11, amended). ADR-078 item 6 reads "No push, no subscription, no email, and the word alert never appears in event-lane copy". The bell carries one in-app item "Watch update: scored a new observation" per scored snapshot, with the life-safety line, linking to /explore?mode=events&event=<id>. A loss threshold or "tell me when" rule is a subscription and needs an ADR amending item 6 first: it is a Phase 18 stub item, and the prototype's "Alert me when…" and "+12 %" are design-only. Market-scan pattern 23 (subscription alerts) is blocked by that item until amended. The item's watch_update payload carries the announced result's event_loss, insured_event_loss (null when the watch has no terms) and unit (null for a mixed portfolio), as stored, and nothing annual is derived from them (#1617, 2026-10-07). As built (#1617, 2026-10-10): the item is the notification type watch_update (ADR-037), written for a feed's own compute of a frame snapshot and never for a cumulative one or a person's Compute, to the watch's creator only. Beside the three keys its payload holds live_event_id, job_id, result_id and title.
  • What a poll looked at (EV-1, #1614, 2026-10-07). A feed's last_poll_summary names window_start (ISO 8601 UTC, or null), the start of the poll's discovery window: the updated_after the USGS ShakeMap adapter sends, the start of the day_range days a FIRMS poll asks for, and null for a replay. The watch's row "In the window" and the outcome of Poll now read "since " from it (ui#771). Its other five keys are polled_at, observations (the observations the poll dispatched as snapshots), events_in_window (the USGS discovery count, null for FIRMS and a replay, which list no events), criterion ({"min_magnitude": …}, {"confidence_min": …} or {}) and refused (the refusal text, or null). A failed poll keeps the previous summary and stamps last_error, and the summary is null until a feed's first completed poll.
  • Units and legend (EV-16). latest_event_loss carries value_unit; the footprint raster has a legend with the snapshot's intensity_unit (K, g). The watch summary serves it as latest_event_loss_unit: the one unit shared by the rows the headline result scored — its exposure dataset's value_unit, or for a portfolio the unit of the members inside the scored snapshot, not of every member — and null when they mix, when the unit is blank and when the watch has no result. The result's own unit is the same reading (#1608, 2026-10-10).
  • The headline's reads (EV-10, EV-17, #1608, 2026-10-10). latest_event_loss is the event_loss of the newest result on a cumulative snapshot, and of the newest result when the watch holds none (a ShakeMap watch, a pre-17b watch). Every row of GET /v1/live-events/{id}/results carries cumulative from its snapshot. The summary also serves latest_severity_level, the level provenance.severity_levels holds for the event_key of the watch's newest snapshot, and null when the feed publishes none, when none is recorded and when the watch has no snapshot.
  • Admin aggregation (EV-8, now both). "Most exposed municipalities" needs per-admin-unit sums of the event result through sum_points_by_admin_unit, cached on the result. The field (#1612, 2026-10-10) is admin_units: [{admin_boundary_id, name, admin_level, event_loss, asset_count}] | null on GET /v1/live-events/{id}/results/{job}: one row per GADM unit, at each result_cell_admin_levels level, that holds at least one asset, summed over every asset of the result and never over the page. The rows read in the result's unit. An asset outside every loaded boundary counts in no unit. compute_event_impact sums once, when it scores the result, and stores one JSON array at results/{job_id}/admin_units.json beside the loss vector; the read only reads it. The write is best-effort and a failed sum does not fail the result. A result with no stored object (scored before the change, or its sum could not be stored) serves admin_units: null, "not available", and [] means no asset stood in a loaded unit.
  • Intensity per asset (EV-3, #1612, 2026-10-07). Every asset row of GET /v1/live-events/{id}/results/{job} carries intensity, the footprint's intensity at the asset's assigned centroid in the snapshot's intensity_unit, stored as an intensity_exp vector beside loss_exp. A result scored before the change serves intensity: null, never 0.
  • A replay watch is created with its series (EV-20, #1807, 2026-10-16). The create dialog offers the series the deployment holds from GET /v1/live-events/replay-series → [{series_dir, name, frames}] (directories holding a manifest.json up to two levels under an allowed series root, at most 200; a missing root is skipped, so no data volume is 200 []), and creates the watch with its feed in one call: POST /v1/live-events takes an optional feed (the feeds route's body). Watch and feed are written in one savepoint, so a refused feed answers its own code (E_VALIDATION with details.problems, E_LIVE_FEED_KIND_UNKNOWN) and leaves no feedless watch behind. POST /v1/live-events/{id}/feeds stays; series_dir is still checked lexically.
  • Portfolios in the footprint (SH-16, FT-23, #1687, 2026-10-16). Data's event drawer lists every portfolio of the organisation with members inside a snapshot's footprint from GET /v1/live-events/{id}/snapshots/{snapshot_id}/portfolios, answering {snapshot_id, valid_at, items: [{portfolio_id, name, asset_count, value, value_unit}]}. asset_count counts the portfolio's members on a cell whose intensity is above zero (a member inside the envelope on a zero cell is not counted), value is Σ weight × value over them in value_unit, and both are null when those members mix units (K27). Items with a value come first by value descending, then the others by asset_count descending; a portfolio without a counted member is absent. No loss is computed. The count runs at read time in the caller's organisation and is cached at hazards/{snapshot_id}/portfolios/{org_id}/{fingerprint}.json, the fingerprint a digest of every portfolios and portfolio_datasets row of the organisation, so a new or deleted portfolio, an attached dataset and a narrowed selection each read under a new key. A watch not visible to the caller, or a snapshot that is not the watch's, answers 404 E_LIVE_EVENT_NOT_FOUND.
  • Post-event report (EV-17). Offered on close, carries the footprint map and the cumulative loss, its checklist says "Event loss" (core/reports/checklist.py:399-419).
  • Pagination and states (EV-18). The assets drawer shows total_assets, the timeline loads every snapshot of the event (500 cap), Compute hides on a closed watch.

6. Reports

A report document references results by id and never copies figures (ADR-085). Eight templates (impact, orsa, portfolio, place, event, esrs_e1, eu_taxonomy_cra, ifrs_s2), every framework item a paraphrase with verified = False (core/reports/frameworks.py:15-28), a checklist of ok | partial | missing, no compliance verdict. Guardrail 7 holds. Rules (RP-1): ORSA is marked available while all ten of its items are to_verify (core/reports/composer.py:123-125 vs checklist.py:494-514). It becomes wording_to_verify with a test that a template whose items are all to_verify can never be available, and the Studio's template card shows the "Wording to verify" badge whenever availability == "wording_to_verify" (rendered reports already print the banner and the "(to verify)" suffix). The prototype's "Engine agreement" section is the ORSA item "Model uncertainty described", satisfied by an analysis widget referencing an engine comparison.

A report reads stored results and never makes a figure up (RP-2, #1638, 2026-10-07). A document names what it reads by its scope and its run, not only by a result id. ReportScope gains admin_boundary_id, the municipality a place report is about, one unit and never a run's clip list admin_boundary_ids. A result widget takes either result_id or a run address {haz_type, scenario, horizon_year}, read against the document's scope through the stored results read of #1664 (run model § 6), with the views by_hazard, horizons, top_areas and map beside summary, curve and top_exposures. The scope's exposure is its portfolio_id, or for a place the default exposure layer of the place's country (default_exposure_dataset, #1660) clipped to admin_boundary_id. With no stored result the widget reads "Not computed yet." and shows no figure and no result id.

Every report is printed for its organization and its reader (SH-24, #1689). The cover carries the organization's logo (POST /v1/orgs/{id}/branding/logo, an SVG or PNG at least 256 px wide, stored at storage_keys.org_logo_key), its display name and a band in its primary colour; the organization's branding.report_footer (200 characters at most) runs at the foot of every page; and money, counts and dates follow the formats preference of the user who asked for the report (currency_display, money, number, area, date, the snapshot's option values), so a report reads as the screen does for the same user.

7. Honesty rules added to run model § 11

  • "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.

8. Docs drift

docs/CLIMATE_LAMA.md §5 lists n_centroids (:2121), centroid_idx and centroid_dataset_id (:2170-2171), none of which exist, says events map by insertion order (:2139) while event_index exists (models/hazard.py:544-545), and lists an FWI curve (:2180) that was never seeded. DT-6 rewrites §5 from the models.

9. Out of scope, and Phase 18

Per-asset occupancy, construction and TIV splits (per-asset binding, D21), per-year discount schedules, portfolio risk transfer, measure entity swaps, a return-period risk metric, population and built-volume curves, probabilistic or ensemble footprints, uncertainty on the event loss, a flood feed, loss-threshold notifications (ADR-078 item 6 amendment), drought data, a multi-hazard aggregate.

Reassess record

2026-10-02, fast: two refuters (science and engine on Fable, business and product) and a completeness critic over claims D1 to D10.

  • Confirmed: the two-shape hazard model and marginal weights, exposure at dataset level, the cumulative FIRMS footprint as a cell-wise maximum, insured event loss through the existing insurance transform, the lane's model.
  • Corrected and adopted: frequency_type derived from storage rather than dropped. The rollup scheme resolved per member cell. The exposure_type column with its index change. The value-weighted score kept and labelled beside a band distribution rather than removed. EAD per hazard from book-scoped runs. One unit or none. benefit_years in the engine rather than a truncated ramp. A combined cost-benefit mode with a canonical order. Exact at_event alignment for masked measures. "Raise the asset" semantics for the depth shift. Score deltas only for hazard-side transforms. The bell as "Watch update" with no threshold rule under ADR-078 item 6. ORSA as wording_to_verify.
  • Refuted as duplicates: the protection standard (AD-2) and the hazard match (AD-5).
  • Missed and folded: AD-2 selects events by rp <= RP_protect. BCR requires cost_unit == value_unit. The cache key must hold deductible, cover and a membership digest. Moment 2 steps 3 and 6 change wording with PF-8 and PF-9.

2026-10-05, amended from the fine-tune record (phase-17b-finetune.md). The quoted UI copy of § 3 and § 7 says "portfolio" where it said "book" and names the saved-work list History (K49, K43), and § 4.3 says what a saved plan keeps and where it is listed (AD-8 as amended, AD-17). Where older prose in this document says "book" it means a portfolio. § 1.2 says that an exposure upload records no bbox today and that EX-18 adds it. What an upload creates is in the phase doc's rows PF-12 and DT-7, and what a saved run, comparison or event replay carries is in run model § 6.

2026-10-07, amended from the delta split of the fine-tune: § 1 names what an upload declares and the upload routes (#1667, #1670), § 3 names indicator_score (#1665), the card fields and reads of PF-7 (#1666) and where a portfolio came from (#1667) and says "portfolio-scoped" where it said "book-scoped", § 5.2 names latest_insured_event_loss, the watch_update payload keys, window_start and the asset row's intensity (#1611, #1617, #1614, #1612), and § 6 names the place report scope and the run address of a result widget (#1638).