Skip to content

Phase 16 — Consistency: consumed history (archive)

Append-only history. Nothing here drives current work. No skill reads this file — it is the consumed record moved out of phase-16-consistency.md on 2026-09-17. Each section below is verbatim, under a provenance note naming its original heading.

Ground truth at open (verified 2026-09-10/11 against UI trunk e9d0c21, backbone trunk 319a7b6, prod v0.24.1; line numbers rot, symbols do not)

  • O1 ui src/components/ConfigPanel.tsx:585-700 renders one row per peril from deriveHazardOptions; the per-peril picker is a DatasetCombobox above HAZARD_SEARCH_THRESHOLD = 8 datasets (:48) and a native <select> at or below it, and neither passes group — the exposure picker does (:348 group: ds.country), rendering the server's derived country verbatim (Phase 14 guardrail 8). ui src/types/index.ts:413 HazardDataset has region but no country; backbone api/v1/hazards.py::_dataset_to_dict (:307) emits no country; the derivation already exists as db/repositories/hazard_repository.py::_hazard_dataset_country_iso3 (:54), used only by the coverage query (:1246). ui#421 item 6 records that GET /v1/hazards has no search/limit-style server filter either.
  • O2 ui src/pages/HomePage.tsx is max-w-5xl with a two-column md:grid-cols-2 grid of five cards, each p-6; RecentRunsCard reads GET /v1/jobs?limit=10 (RECENT_RUNS_LIMIT = 10, three lines per row incl. the error line), PortfoliosCard limit=5 with a four-line row, CoverageCard a full-width country × peril table. The scroll owner is Home's root (flex-1 overflow-y-auto; the runtime pin is tests/e2e/smoke/scroll-contract.spec.ts — tests/scroll_contract.test.ts is a repo-wide source grep). The failed-run row prints Error code E_VALIDATION (kept on purpose, backbone #1182) and not the job's error_message, which JobListItem carries too. ui#380 (Phase 15, open) adds a sixth card, Live now, behind features.live_mode. The smoke suite's viewport is 1280×800 (tests/e2e/smoke/playwright.config.ts:72) and home.spec.ts accepts an empty Home (:124-145).
  • O3 ui src/components/NotificationBell.tsx::NotificationRow — the row's only handler is onRead(item.id); src/lib/notificationText.ts::notificationDeepLink exists and is used by Home's NotificationsCard only. Payloads (backbone): job_complete carries job_id, job_type (impact_calc | event_impact_calc), result_id, title (worker/tasks.py:1346-1358, 1811-1820) — the event lane's result_id is an event result, for which /assess?result= is the wrong destination; job_failed carries title + error_code (core/notifications.py::job_failed_payload); dataset_available {dataset_id, title} (aggregate_and_commit.py:1901); download_ready {download_id, target_path} and the report variant {job_id, job_type, template, object_key}; admin_approval_needed {kind, source_id, item_id, title, version}. The dropdown has no "mark all read" (Home's card has one) and no footer.
  • O4 ui src/components/Dashboard.tsx:483 holds computeJobId in component State; useJobPoller(state.computeJobId) (:1668) lives in the page; App.tsx mounts <Dashboard /> at /assess only, so navigating unmounts it and the id is gone. A batch (?batch=, ASSESS_BATCH_PARAM, :137) and a comparison re-attach on mount from the URL; a single run has no URL param. GET /v1/jobs (api/v1/jobs.py:67) filters by status and job_type.
  • O5 Two halves. (a) Data: prod's admin_boundaries holds four rows — CYP 0 Cyprus, CYP 1 Larnaca, GRC 0 Greece, GRC 1 Attica (psql on prod, 2026-09-10) — the smoke fixtures tests/fixtures/admin_boundaries/gadm41_{GRC,CYP}_{0,1_*}.json.gz, loaded once through scripts/load_admin_boundaries.py --geojson (single-Feature mode). (b) Contract: ui src/components/ResultMap.tsx switches to the choropleth tier below CHOROPLETH_MAX_ZOOM = 8 (:72) after a 300 ms moveend debounce, refetches with admin_level = 1, and applyResultTier hides the point layer whenever the answer has any polygon (polygons.length > 0); the admin fetch replaces geojson in Dashboard (:2760-2770), so the points are gone until the next refetch. Backbone api/v1/results.py::_build_admin_aggregation_geojson (:188) returns one feature per polygon at that level with eai_exp_sum/point_count and no collection-level coverage figure; core/admin_aggregation.py groups by the polygons that exist. A Greece-wide result therefore renders as Attica only. The polygon popup says "N assets in this cell" (popupHtml, map.popupCellAssets) — cell copy on an admin unit — and read "1 asset · EAI €78" on the Attica polygon of a 216,175-asset run (owner screenshot), which the closing test must explain or fix.
  • O6 ResultMap.tsx:526 and live/LiveEventMap.tsx:302 create new maplibregl.Popup({ closeButton: true, closeOnClick: false }); MapLibre's default close button is a 16 px glyph; src/index.css styles nothing of it; handleBackgroundClick fires the selection callback but never removes the popup.
  • O7 Prod GET /v1/geocode → 503 E_GEOCODE_UNAVAILABLE reason=connection. docker-compose.prod.yml:86-97 forces GEOCODER_ENABLED: "true", GEOCODER_BASE_URL: http://nominatim:8080, GEOCODER_ADVERTISED: ${GEOCODER_ADVERTISED:-false} (#1336 landed); nominatim (:342-357, mediagis/nominatim:5.3, PBF_URL greece-latest.osm.pbf, UPDATE_MODE: none, healthcheck start_period 45 m) sits behind profiles: [geocoding]; .github/workflows/deploy-prod.yml appends only --profile climada to its three P= assemblies (:457-474, :584-595, :896-903) and runs up -d --remove-orphans, so a hand-started container is reaped on the next deploy. #1322 (closed 2026-09-10) reaffirmed pin-drop-only; ui#424 (open, Phase 15) hides the box when features.geocoding is false.
  • O8 ui src/App.tsx:129-137 renders NAV_ITEMS as plain <Link>s with one class string; no NavLink, no aria-current.
  • O9 ui src/components/lookup/LookupMap.tsx draws the selected hazard as a titiler raster (/v1/tiles/cog/{id}/{z}/{x}/{y}.png?bidx&rescale&colormap_name, client :116-137) at 70 % opacity with a continuous ramp; src/map/legend.ts maps six ramp names to CSS gradients. The backbone proxy (api/v1/tiles.py:111-145, core/tile_render.py::parse_render_params) accepts bidx, rescale, colormap_name only. Score schemes exist per peril (models/score_scheme.py, ten bands with lower/upper edges, migration 0057) and drive the Lookup card's band — the raster and the card speak different scales.
  • A1 Native dialogs: ConfigPanel.tsx:476 confirm(...) and :481 alert(...) on hazard delete; ImpactFunctionEditor.tsx:96 window.confirm. Three hand-rolled dialogs coexist (admin/ConfirmDialog.tsx, auth/ApiKeysPage.tsx::KeyActionDialog, pages/RunsPage.tsx::DeleteDialog) — ui#421 item 4.
  • A2 CostBenefitPanel.tsx carries the rail-squash defect ui#403 fixed in ConfigPanel (ui#421 item 8: no min-h-0, no shrink-0 on the footer).
  • A3 Home "Start here" chips print Wildfire · 7 datasets · 0 computable for indicator perils — reads as broken; the coverage payload already knows the peril is indicator-only (Assess renders "Indicator — no EAI" for it).
  • A4 The owner's browser audit could not be repeated by this session (the Chrome extension was not connected); the audit above is code-and-prod-API based. A1–A3 and A5 are the audit's additions; the owner's walk stays the primary source.
  • A5 ui#421 item 1: the hand-copied maplibre-gl test mock returns a fresh canvas per call, so every cursor/hover assertion in ~10 map test files is vacuous — a Phase 16 issue that touches ResultMap.test.tsx (O5, O6) must use a real canvas mock or its popup/cursor checks prove nothing.

Design record (full prose)

Decision Choice Why (evidence)
D1 Numbering and concurrency Phase 16 is Consistency; the revamp charter stub becomes Phase 17 (ui-revamp.md header, plan.md rows, phase-14-solidify.md:833, phase-15-live-risk.md D1 mentions annotated, not rewritten). Phase 16 runs beside Phase 15 under the combined ≤3-builder cap; every Phase 16 item that touches a Phase 15 hotspot depends on the Phase 15 issue that owns it (Deps: written fully qualified): UI App.tsx/client.ts/types/index.ts/contract-map.ts → after ui#378 + ui#379; HomePage.tsx → after ui#380; backbone worker/tasks.py → after #1293; nothing in Phase 16 edits live_events.py, live_feeds.py or components/live/* beyond the popup CSS class owner instruction 2026-09-11; Phase 13 ran beside Phase 11 on the same rule; hotspot table below
D2 Hazard country comes from the server (O1) GET /v1/hazards and /{id} rows gain country: str \| null — the ISO3 _hazard_dataset_country_iso3() already derives for coverage — plus a ?country= filter copied from the exposure catalogue (api/v1/exposure_datasets.py:86-88,139 + exposure_dataset_repository.py:363-375: ISO3, upper-cased server-side, NULL means "not derivable" and never matches). Shape (reassess correction): get_dataset_by_id keeps returning the ORM row (results.py:198 hands it to resolve_admin_aggregation, which reads org_id/npz_path); two new repository methods list_datasets_with_country(..., country=None) and get_dataset_with_country(id) return {"dataset": ds, "country": iso3} dicts exactly as the exposure repository does, the filter applied in SQL (a test proves paging is not post-filtered), and _dataset_to_dict(ds, *, event_axis, country) takes country keyword-only with no default (the #920 rule). The UI hands group: ds.country to the peril's DatasetCombobox and renders <optgroup label> in the native <select>; null is the ungrouped bucket labelled as the exposure picker labels it. The peril row keeps its (N) count and its label goes through hazardLabel() like every other surface — today the rail (ConfigPanel.tsx:594) and HazardUploadModal.tsx:333 read glossary:hazardTypes.* directly and print "River Flood" beside a card that prints "River flood" (critic finding; a vitest pins the two namespaces to one casing). /datasets gains a country FilterSelect on both catalogues (W2.7) so the Assess rail and the Datasets page name a dataset's place the same way. search/limit on /v1/hazards stay deferred (ui#421 item 6) Phase 14 guardrail 8 forbids client-side grouping; the derivation and the combobox grouping both exist, only the wire is missing
D3 Home is one viewport (O2, A3) Target: at 1440×900 with prod's volume (2 countries, 10 jobs, ≥5 books, ≥10 notifications) Home's scroll owner has scrollHeight ≤ clientHeight + 120 px — asserted by a Playwright smoke check at that viewport on the smoke stack's seeded data, and by a vitest layout test on the row counts. Means: container max-w-7xl; xl:grid-cols-3 (Start here · Recent runs · Notifications) with Portfolios + Live now (ui#380's sixth card, mounted only when features.live_mode — the assertion runs with the flag on, the worst case) + Coverage on the second row (xl:col-span-1 · xl:col-span-1 · xl:col-span-1, Coverage spanning full width on a third row only if it does not fit); cards p-4; Recent runs limit=5, one line per row (title · status pill · relative time; the failed row keeps its error_code inline — backbone #1182's decision, RecentRunsCard.tsx:28-29 — and gains the error_message on a second line; an "All runs" link to /runs); Portfolios limit=3, two lines per row; Start-here chips one wrapped row per country, indicator perils labelled indicator instead of 0 computable — which needs indicator: bool on the coverage cell (W0.7; CatalogCoverageCell carries only dataset_count/computable_count today and guardrail 8 forbids deriving it client-side); Coverage table unchanged in content. Below xl the grid stacks and may scroll. Measurement (reassess corrections): the smoke suite's default viewport is 1280×800 (tests/e2e/smoke/playwright.config.ts:72), so home.spec.ts scopes test.use({ viewport: { width: 1440, height: 900 } }) in a dedicated describe and states that 1280×800 is deliberately not asserted; the existing runtime scroll pin is tests/e2e/smoke/scroll-contract.spec.ts (tests/scroll_contract.test.ts is a source grep); and because the smoke stack's Home may be empty (home.spec.ts:124-145 accept the empty state), the check first asserts a volume floor — ≥ 5 jobs, ≥ 3 books, ≥ 1 coverage row, ≥ 1 notification, seeded through the real APIs inside the spec — and fails, never skips, below it; prove-it-fails = RECENT_RUNS_LIMIT back to 10 at that volume owner preference "one view, a little scrolling on small screens"; every number above is a measured layout constraint, not a redesign; a viewport assertion on an empty page is the vacuous-gate class Phase 14 counted eight of
D4 Notification rows navigate (O3) One rule in notificationDeepLink, consumed by both the bell and Home: job_complete + job_type = impact_calc → /assess?result=<result_id>; job_complete + event_impact_calc → /live/<live_event_id> (Phase 15 route, ui#379) — the payload gains live_event_id and portfolio_id (backbone, emitted where the event compute already has both in scope); job_failed → the row expands inline to show error_code + title (no page lists jobs today — /runs is saved runs; a ?job= deep link is defer: → the Runs hub follow-up in Out of scope); dataset_available → /datasets (the Datasets page reads no selection from the URL today — DatasetsPage.tsx/HazardCatalog.tsx have no useSearchParams; a ?hazard=<id> selection is defer: → Out of scope); download_ready with job_type = report_render → (reassess correction) the report route is result-keyed (GET /v1/reports/{result_id}/report, api/v1/reports.py:151) and the UI's downloadComposedReport(batchId, template) is batch-keyed (client.ts:1075), while the emitter (worker/tasks.py:3913-3941) stamps a dynamic subject key — result_id for a single render, a pack key for a matrix — so: W0.3 makes the emitter stamp result_id or batch_id explicitly under those names, and the row links to /assess?result=<id> or /assess?batch=<id>, where the existing report button downloads it (never downloadComposedReport(job_id) — that 404s); for the admin dataset-download kind (download_id; the only route is GET /v1/admin/downloads/{download_id}) → /admin/datasets; admin_approval_needed → /admin/pending-ingests; system_announcement → expands inline. Clicking marks read and navigates; the dropdown gains Mark all read (a wiring job — client.markAllNotificationsRead() exists at client.ts:1494 and Home's card calls it; W2.3 lifts it into useNotifications) and a footer link to Home's Notifications card. NotificationsCard stops carrying its own copy of any of this. portfolio_id is optional on the wire (portfolio_uuid may be None) ADR-037: the backbone does not interpret payloads, so the destination is read off keys the emitter already stamps; NotificationsCard proved the pattern; a row that only marks itself read is the owner's finding verbatim
D5 Runs are owned above the routes (O4) A RunProvider mounted in AppShell above <Routes> owns the in-flight ids {computeJobId, batchId, comparisonId} and the labels the header needs; the pollers (useJobPoller, useBatchPoller, useComparisonPoller) run inside it with ASSESS_POLL_OPTIONS; Dashboard reads and writes them through the context instead of its own State. A single run is also written to the Assess URL as ?job=<id> (ASSESS_JOB_PARAM, through the same setUrlParam helper, placed below ?batch=/?comparison= in the precedence ladder Dashboard.tsx:168-180 documents — a URL carrying both resolves to the batch, as :651-653 already does) so a reload re-attaches. The header shows a run pill (spinner + "Assessment running" → click → /assess) while any id is in flight and a 5 s toast "Assessment finished — Open" when a run completes on another route; the toast is the provider's own (runToast: {tone, message, action?}, rendered by AppShell beside the auth toast) because AuthContext.toast is a single red error string with no action (AuthContext.tsx:26,255; App.tsx:118) and stays the error channel; the bell's job_complete remains the durable record. Result data (state.result, geojson) stays on Assess: only ids and status move up. Rejected by the pass: keeping Dashboard mounted-hidden (keeps a WebGL map and the whole payload alive on every route) and sessionStorage (a stored id outlives logout and org switch) the batch already re-attaches by id; lifting ids is a few hundred bytes, lifting GeoJSON is not; a header pill is what tells the user the app is still working
D6 The regional tier is honest (O5) (a) Data: scripts/load_admin_boundaries.py --geojson learns to load a GADM per-country FeatureCollection (every feature in the file, coarsest level first; the file's own levels decide what is loaded — the mode ignores the _should_load_admin_2() env gate that only the archive path consults, and says so) with a test under tests/scripts/; the loader rejects a FeatureCollection today (:333-338), --archive takes only the global gadm_410-levels.zip with no country filter (:534-545), and scripts/extract_admin_boundaries.py confirms GADM's per-country GeoJSON release is exactly the collection the loader refuses. An owner-run ops tracker loads GADM 4.1 GRC levels 1–2 and CYP levels 1–2 on prod through it and records SELECT iso3, admin_level, count(*) before and after, per level. (b) Contract: the admin aggregation answers with collection-level properties.admin_coverage = {assigned_points, total_points, ratio} — both sides in assets (reassess correction: total_points = int(count_by_centroid.sum()), every exposure point the stored result attributed to a centroid, viewport-independent; assigned_points = Σ the per-feature point_count results.py:224 already computes, from the unclipped aggregation so a bbox-filtered request never reports low coverage, with an assertion that array_indices are disjoint across polygons; ratio = assigned/total, 0.0 when total is 0); named admin_coverage because _result_to_dict already promotes a differently-denominated scope coverage pair (#1180, results.py:245-256). Per-feature admin_name already exists. ResultMap shows the choropleth only when admin_coverage.ratio ≥ 0.95, keeps the point tier otherwise with a one-line note ("Regional view covers N % of this result — showing assets") in the layer panel, and fails closed: no coverage figure fetched ⇒ point tier (the tier switch at ResultMap.tsx:551-562 runs before the refetch guard and the refetch is suppressed entirely on a centroid-MVT result, :571); the admin fetch merges polygons into the current payload instead of replacing it, so the points never vanish. The polygon popup reads "N assets in · EAI €X". (c) Proof and diagnosis: the "1 asset · €78" popup on the Attica polygon of a 216,175-asset run is a real defect (reassess: results.py:224 sums count_by_centroid, a per-exposure-point bincount from core/eai_alignment.py:447-478, and discards the cell count admin_aggregation.py:218 builds — so point_count is an asset count). Two mechanisms are visible and a fixture that covers the result by construction reproduces neither: (i) eai_alignment.py:448-450 sizes the arrays to the highest assigned index + 1 and results.py:220-222 drops every array_index ≥ n_centroids, so a grid whose assigned indices stop early zeroes whole polygons; (ii) results.py:127 scopes the rollup to portfolio_scope_for_job, so a portfolio-addressed job aligns only that book's points while the headline EAD came from the full run. W0.2 therefore carries a diagnosis box run against the owner's prod result (compare count_by_centroid.sum(), n_centroids, max(array_index) from fetch_grid_geometry, and the job's portfolio scope; recorded on the issue, owner-run via the W3.1 tracker if a builder cannot reach prod) and a fixture test asserting Σ eai_exp_sum = EAD within 1 % under full coverage and ratio < 1 under a partial boundary set the map must never hide assets the boundaries do not know; a coverage figure is one sum the backbone already computes; the fixture set covers Attica and Larnaca only by construction (tests/fixtures/admin_boundaries/README.md)
D7 Popups close like popups (O6) One shared rule in src/index.css for .maplibregl-popup-close-button (≥ 28 px hit area, 18 px glyph, brand focus ring) and one constructor option set (closeOnClick: true, closeOnMove: false) applied to every maplibregl.Popup in src/ (ResultMap, LiveEventMap); Escape closes through the existing useEscapeLayer; the background-click handler removes the popup before clearing the selection. Test on ResultMap.test.tsx with a real canvas mock (A5) two call sites, one CSS file; MapLibre's closeOnClick is the documented switch
D8 Address search: enable geocoding on prod (O7) — reverses #1322 on new evidence The owner re-reported the box as "does not work" on 2026-09-10, after the pin-drop-only decision of the same day, as a finding to fix — that is the new evidence the framework requires. Enable = (i) a buildable workflow PR: every P= assembly in deploy-prod.yml (:472-475, :594-596, :902-903) appends --profile geocoding when .env.prod carries GEOCODER_ADVERTISED=true — the climada gate keys on the functional CLIMADA_SIDECAR_URL, not a dedicated flag (reassess correction), so the geocoding gate keys on the functional key that already exists in .env.example.prod:176 and is interpolated into the api service; one flip does both. The "all seven/eight services" comment and the container-id proof gain nominatim, docker-compose.hetzner.yml needs nothing (it defines no nominatim), tests/test_env_template_parity.py passes untouched (prod-only keys below the tail marker are not in the shared spine), and docs/DEPLOYMENT.md gets the enable checklist; inert until the flag is set. (ii) An owner-run ops tracker: set GEOCODER_ADVERTISED=true in .env.prod (NOMINATIM_PASSWORD is already on the host, per #1322's closing comment), ./dc --profile geocoding up -d nominatim, wait for the Greece import (wall time and disk [ASSUMPTION: ~30–60 min, ≤ 10 GB on the CX43 — measure and record]), verify GET /v1/geocode?q=Thessaloniki → 200 with candidates and GET /v1/info → features.geocoding = true, then the next deploy keeps it. ui#424 stays as filed (the flag now reads true). Cyprus addresses will not resolve from a Greece-only PBF — defer: a merged PBF or a second instance → Out of scope with the reason #1322's own comment measured the enable cost as "a host action plus a three-site workflow change" and found no env plumbing missing; the rejected alternative (hide the box, ui#424) leaves Moment 1 step 1 on pin-drop only
D9 The active nav entry is visible (O8) NAV_ITEMS render as NavLink (react-router-dom ^6.30) with end only on /; the active class is the rail's selected treatment (bg-brand-primary/10 text-brand-primary font-medium rounded-md px-2 py-1) and aria-current="page" comes from NavLink itself. No mapping table (reassess correction): v6 prefix matching already lights the parent for /portfolios/:id, /portfolios/:id/events/:eventId, /runs/compare, /settings/api-keys, /admin/* and /live/:id. App.test.tsx (a ui#379 hotspot) asserts exactly one [aria-current="page"] for /, /assess, /lookup, /portfolios, /portfolios/abc, /datasets, /runs, /runs/compare, /settings/api-keys, /admin/users and zero for the deliberately unparented routes /measures, /matrix, /nope react-router's NavLink is the component the app already depends on; the selected style exists in ConfigPanel
D10 The hazard raster speaks the score's language (O9) Backbone: the tile proxy gains ?classed=score — when the dataset's peril has a score scheme, the proxy passes titiler (pinned 0.19.2, docker-compose.yml:118) a discrete colormap built server-side from the scheme's ten lower/upper edges (merged to the five RAG bands the card uses, colours from the card's palette), else it answers as today. classed is a field on RenderParams (core/tile_render.py:193-214) so it reaches to_query and cache_token together (tiles.py:160-166); classed and colormap_name together are refused with E_VALIDATION (no precedence rule today — reassess miss); the server-minted map does not breach tile_render.py:14's no-raw-pass-through rule, which forbids relaying caller bytes. First check of W0.4: one curl against the local titiler with the interval form [[[min,max],[r,g,b,a]],…] — titiler's docs show only the value-keyed form and defer intervals to rio-tiler, so if 0.19.2 rejects it the fallback is a value-keyed map at the band edges. The band list rides the existing legend payload — RasterLegend on GET /v1/hazards/{id}/layers (models/layer_spec.py:64-100, hazards.py:1415-1445), not a new /legend route — so the UI never re-derives an edge (guardrail 8). UI: the Lookup and Datasets legends render band swatches with labels when bands are present, the continuous gradient otherwise; the raster layer fades by zoom while the answer-cell outline and the value chip stay — expressed as ['interpolate', ['linear'], ['zoom'], 12, opacity, 14, 0] recomputed on every slider change (ui#305's opacity control drives setPaintProperty('raster-opacity', opacity) at LookupMap.tsx:159-164; a constant-anchored fade would fight it), and the layer card says why ("1 km cells — zoom out to read the pattern"). Default opacity stays 70 % the Lookup card already bands the same value; a continuous ramp of a 1 km product at street zoom answers nothing; MapLibre's raster-opacity supports interpolate expressions
D11 One dialog (A1) admin/ConfirmDialog.tsx is promoted to src/components/ConfirmDialog.tsx and replaces the native confirm/alert in ConfigPanel, window.confirm in ImpactFunctionEditor, KeyActionDialog and DeleteDialog; failures render in the dialog, never in alert() ui#421 item 4's own recommendation; a native dialog blocks the page and looks nothing like the app
D12 Rail parity (A2) CostBenefitPanel gets ui#403's three-line parity fix (reassess correction — the aside already has flex-shrink-0; its real defect is overflow-y-auto on the aside, which makes it a second scroll owner so the Run button scrolls away): aside :133 → overflow-x-hidden (drop overflow-y-auto), content :139 gains min-h-0, footer :331 gains shrink-0 + data-testid="cost-benefit-panel-footer", mirroring ConfigPanel.tsx:505/536/1112; test = ui#403's shape (with a long measure list the footer stays inside the aside's client rect) latent defect, cheapest item of the set

Orchestration sketch (for /phase-split → /phase-build; verified sizes come from /analyze)

Backbone: P16-B1 W0.1 ∥ W0.2 ∥ W0.5 ∥ W0.6 ∥ W0.7 (independent, day one) · P16-B2 W0.3 ∥ W0.4 (W0.3 after #1293 merges). UI: P16-U1 W1.2 ∥ W1.3 ∥ W1.4, then W1.1 once ui#379 lands · P16-U2 W2.1 → W2.7, W2.2, W2.3 (after P16-B1, ui#378–#380; client.ts consumers serial) · P16-U3 W2.4 → W2.5, W2.6 ∥ (after P16-B2). Ops O1 W3.1 (after P16-B1's deploy), O2 W3.2 (after W0.6's deploy). R the release; W4 after the walk. Seven backbone build cards, eleven UI, two trackers, one docs card — roughly six orchestrator sessions at the measured ≈80–100 min per serial Opus·xhigh step; combined live builders ≤ 3 with Phase 15; every UI session ends with an e2e-smoke.yml dispatch; the backbone trunk gate stays debounced.

Wave plan (filed 2026-09-11 by /phase-split phase-16; sizes from /analyze)

Issue map — backbone milestone 16 (Phase 16 -- Consistency): W0.1 #1369 · W0.2 #1370 · W0.3 #1371 · W0.4 #1372 · W0.5 #1373 · W0.6 #1374 · W0.7 #1375 · W3.1 #1376 (ops) · W3.2 #1377 (ops) · W4.1 #1378 · W4.2 #1379 (ops). UI milestone 7: W1.1 ui#427 · W1.2 ui#428 · W1.3 ui#429 · W1.4 ui#430 · W2.1 ui#431 · W2.2 ui#432 · W2.3 ui#433 · W2.4 ui#434 · W2.5 ui#435 · W2.6 ui#436 · W2.7 ui#437. Sizing: backbone

1369 #1375 #1378 Sonnet·xhigh (#1369 on the 2a trial), #1370–#1374 Opus·xhigh; UI ui#427 ui#428 ui#430

ui#437 Sonnet·xhigh (428/437 on the 2a trial), ui#429 Opus·high, ui#431–#436 Opus·xhigh; no Fable candidates. Trackers #1376 #1377 #1379 carry ops + model: none and are never passed to /phase-build.

Backbone (NO-PR-CI; docs/CLIMATE_LAMA.md is a hotspot of #1369/#1370/#1375, so that chain is serial): - P16-B1 /phase-build 1369 1370 1373 1374 1375 --no-pr-ci --max-runtime 270 — day one. - P16-B2 /phase-build 1371 1372 --no-pr-ci --max-runtime 120 — after #1293 merges (#1371's dep). - P16-B3 /phase-build 1378 --no-pr-ci — after every build issue in both repos and the walk (#1379). - O1 #1376 (owner: GADM load + diagnosis) after P16-B1 is deployed; O2 #1377 (owner: geocoding) after #1374 is deployed; R the release; #1379 the re-walk + mid-review.

UI (run from the climate-lama-ui checkout; e2e-smoke.yml dispatched after each session): - P16-U1 /phase-build 428 429 430 --no-pr-ci --max-runtime 120 — day one (independent). - P16-U2 /phase-build 427 431 432 --no-pr-ci --max-runtime 200 — after ui#378, ui#379, ui#380 land and P16-B1 merges (#1369, #1375); types/index.ts serialises 431 → 432. - P16-U3 /phase-build 433 434 437 --no-pr-ci --max-runtime 200 — after P16-B2 (#1371) and ui#379; contract-map.ts serialises 433 → 437. - P16-U4 /phase-build 435 436 --no-pr-ci --max-runtime 200 — after P16-B2 (#1370, #1372), ui#428 and ui#434; client.ts / types/index.ts serialise the pair.

Start: P16-B1 and P16-U1 together (combined live builders ≤ 3 with Phase 15).

Decisions taken autonomously (for the pre-split /reassess)

The owner asked for autonomous planning with ambiguity resolved by a fast reassess. These are the calls a refuter should try hardest to break:

  1. Numbering (D1) — "Consistency" takes 16; the revamp stub moves to 17; the phase runs beside Phase 15 with fully-qualified cross-milestone deps.
  2. Country on the wire (D2) rather than grouping hazards client-side by region or bbox.
  3. One-viewport Home as a measured Playwright assertion (D3), with the row limits 5 / 3 and a three-column grid at xl.
  4. Deep links read off payload keys the emitter already has (D4), adding live_event_id on the backbone rather than a UI lookup; job_failed expands inline because no page lists jobs.
  5. A RunProvider above the routes plus ?job= (D5) rather than keeping Dashboard mounted or persisting the job in localStorage.
  6. Coverage-gated choropleth with a 0.95 threshold and merge-not-replace (D6b), plus a full GADM load on prod (D6a) rather than removing the tier.
  7. Enabling geocoding on prod (D8), reversing #1322 on the owner's re-report, with the profile grep-gated in the deploy workflow.
  8. Classed tiles built server-side from the score scheme (D10) rather than a client-side colormap, a contour layer or removing the raster.
  9. Promoting ConfirmDialog app-wide (D11) as one issue.
  10. Nothing on /live, /measures or dark mode — pointers in Out of scope.
  11. The geocoding profile gates on the functional GEOCODER_ADVERTISED=true rather than a dedicated flag (the climada precedent gates on its URL).

Reassess record (2026-09-11, fast, 3 agents — a backbone refuter, a UI refuter, a completeness critic; ≤20 read-only calls each, opus)

18 claims: 10 confirmed, 8 corrected, 0 refuted; 14 items added. Corrected: the hazard country rides new row-dict repository methods (never a widened get_dataset_by_id, which results.py:198 consumes as an ORM row) with the filter in SQL; the coverage figure is assets on both sides and named admin_coverage (a scope coverage pair already exists on the result payload); the "1 asset · €78" popup is a real defect with two visible mechanisms and needs a diagnosis on the prod result, not a fixture that covers it by construction; the report download_ready payload stamps a dynamic subject key and the download route is result-keyed while the UI method is batch-keyed — the emitter stamps result_id/batch_id explicitly and the row links to Assess; D9 needs no mapping table (v6 NavLink prefix-matches) but must name the unparented routes; the completion toast cannot reuse AuthContext.toast (error-only, no action); CatalogCoverageCell has no indicator flag (W0.7 added); the CostBenefitPanel defect is the aside's own overflow-y-auto, not a missing shrink. Added: classed × colormap_name mutual exclusion; the interval colormap form verified first against titiler 0.19.2; the FeatureCollection mode's level handling stated; the geocoding gate keyed on GEOCODER_ADVERTISED; ui#380's sixth Home card in the grid and the viewport check run with live mode on; the check's volume floor (fail, never skip); the smoke viewport is 1280×800 so the 1440×900 case is scoped with test.use; error_code kept beside the message; fail-closed tier on a missing coverage figure (centroid-MVT results never fetch one); the zoom fade anchored on the slider's opacity; App.test.tsx is a ui#379 hotspot; the peril-label casing split (glossary "River Flood" vs lookup "River flood") folded into W2.1; a country filter on both Datasets catalogues (W2.7). Rejected by the lenses: keeping Dashboard mounted-hidden; sessionStorage re-attach; a new mark-all contract (the client method exists); a nested-route mapping table; "point_count counts cells"; --archive as a cheap per-country load; a nominatim entry in the hetzner override. Unverified after the pass: the rio-tiler interval colormap form on 0.19.2 (W0.4's first check); models/score_scheme.py's band shape beyond core/scoring.py:793; the Greece import's wall time and disk on the CX43.

Inputs from open residue and their disposition

Item Disposition
ui#424 mount the address box only when features.geocoding stays on Phase 15 as filed; after D8 the flag reads true on prod, so nothing hides
ui#421 item 1 (vacuous maplibre mock) W1.2 introduces the shared stable-canvas mock and adopts it in ResultMap.test.tsx; the remaining files are ui#421's own work
ui#421 item 4 (three dialogs) W1.3 (D11)
ui#421 item 8 (CostBenefitPanel rail) W1.4 (D12)
ui#421 items 2, 3, 5, 6, 7 unchanged — altitude/duplication, not consistency; item 6 pointed at from D2
ui#377 item 2 (JobListItem has no portfolio id) unchanged; D4 adds portfolio_id to the notification payload only
ui#358 (useScenarioHorizonQuery writes defaults) unchanged, Phase 15
#1322 (pin-drop-only) reversed by D8 with the reason recorded; #534 is superseded on prod once W3.2 runs

Split readiness (2026-09-11 — what /phase-split consumes, pinned so it invents nothing)

Milestone Phase 16 -- Consistency in both repos, label milestone:phase-16 (neither exists — the split creates them, backbone first). No existing issue is reused; ui#421/#424/#377/#358 stay where they are (table above). Every card below is one build issue; Files: seam paths are marked (seam), shared files (hotspot); cross-repo deps are written fully qualified; trackers carry model: none and the ops label and are never passed to /phase-build. Standing riders on every buildable card: lint + type check green; the touched test files re-run green in isolation at the merged SHA; the demoable slice still runs; a contract-map.ts entry per new UI client method; a prove-it-fails box wherever a test or gate is added; a seam-resolution box wherever a seam path is touched; PR typing per guardrail (Phase 14 D15).

Contracts pinned (closed list — a builder implements these, a split does not redesign them)

  • GET /v1/hazards row += "country": "GRC" | null (ISO3 upper, null = not derivable). Query country: str | None — upper-cased, matched in SQL against _hazard_dataset_country_iso3(); null rows never match. Repository: list_datasets_with_country(...) / get_dataset_with_country(id) → {"dataset": HazardDataset, "country": str | None}; get_dataset_by_id untouched. _dataset_to_dict(ds, *, event_axis, country).
  • Result GeoJSON at an admin level (GET /v1/results/{id}/geojson?admin_level=1): collection properties += "admin_coverage": {"assigned_points": int, "total_points": int, "ratio": float} (assets both sides; unclipped; ratio 0.0 when total 0); feature properties keep admin_id, admin_name, iso3, admin_level, eai_exp_sum, point_count. Raw-point responses carry no admin_coverage.
  • Notification payloads (additive): job_complete with job_type == "event_impact_calc" += live_event_id: str, portfolio_id: str | null; download_ready with job_type == "report_render" += exactly one of result_id / batch_id under those names (the dynamic subject key stays for compatibility).
  • Tile proxy: GET /v1/tiles/cog/{dataset_id}/{z}/{x}/{y}.png?classed=score — allowed value score only; classed + colormap_name → E_VALIDATION; RenderParams gains classed, present in to_query and cache_token; the colormap is minted from the peril's active score scheme (five RAG bands from the ten edges; colour hexes = the Lookup card's riskLevels palette, copied into the backbone as constants with the source named); a peril without a scheme answers as today and RasterLegend says so. RasterLegend (models/layer_spec.py, on GET /v1/hazards/{id}/layers) += "bands": [{"lower": float, "upper": float | null, "label": str, "color": "#hex"}] | null.
  • Coverage cell (GET /v1/catalog/coverage): += "indicator": bool (true when every dataset of the cell is indicator-only, from the same indicator flag HazardDataset rows carry).
  • scripts/load_admin_boundaries.py --geojson PATH: accepts a Feature (as today) or a FeatureCollection (every feature, sorted by level ascending, upsert on the GADM key like the archive path); the env gate _should_load_admin_2() is not consulted in this mode.
  • deploy-prod.yml: in each P= assembly, after the climada block: if grep -qE '^GEOCODER_ADVERTISED=true$' .env.prod; then P="${P} --profile geocoding"; fi; the services comment and the container-id proof list nominatim when the profile is on.
  • UI URL params: ASSESS_JOB_PARAM = 'job' on /assess, below batch/comparison in precedence. UI routes unchanged. UI client: listHazardCatalog gains country?: string; getResultGeoJSON returns admin_coverage in the typed payload; getHazardLayers legend type gains bands; listCatalogCoverage cell gains indicator; useNotifications gains markAllRead.
  • UI copy keys (English only, per-surface files): results.json map.popupAdminAssets ("{{count}} assets in {{name}}"), layers.regionalCoverageNote; home.json startHere.indicator, recentRuns.allRuns; notifications.json panel.markAllRead, panel.viewAll; common.json run.running, run.finished, run.open; lookup.json legend.bandsNote.

Split cards (one per build issue)

Filed 2026-09-11 by /phase-split phase-16 — W0.1 → #1369 · W0.2 → #1370 · W0.3 → #1371 · W0.4 → #1372 · W0.5 → #1373 · W0.6 → #1374 · W0.7 → #1375 · W3.1 → #1376 · W3.2 → #1377 · W4.1 → #1378 · W4.2 → #1379 · W1.1 → ui#427 · W1.2 → ui#428 · W1.3 → ui#429 · W1.4 → ui#430 · W2.1 → ui#431 · W2.2 → ui#432 · W2.3 → ui#433 · W2.4 → ui#434 · W2.5 → ui#435 · W2.6 → ui#436 · W2.7 → ui#437. Split decisions beyond the cards: /datasets keeps its region filter beside the new country one; the dataset_available row links to /datasets (no URL selection exists); the report_render row links to /assess?result= or /assess?batch= rather than calling the batch-keyed download method; ui#378's "no later UI issue edits client.ts/types/index.ts" clause is annotated with this phase's consumers (comment on ui#378).

Id · title Grounding (checked where) User-visible Files Deps · Parallel · draft size · kind Required boxes Out of scope
W0.1 feat(api): country on hazard rows + ?country= filter PARTIAL — derivation exists (hazard_repository.py:54-110), unused by list_datasets/_dataset_to_dict (hazards.py:307); exposure precedent exposure_datasets.py:86-139, exposure_dataset_repository.py:363-375 catalog rows say which country a hazard dataset covers src/climate_lama/api/v1/hazards.py, src/climate_lama/db/repositories/hazard_repository.py, tests/test_api/test_hazards.py, tests/test_api/test_hazards_country.py, docs/CLIMATE_LAMA.md (hotspot) none · yes · S · build filter applied in SQL (prove-it-fails: post-filter the page and the paging test goes red); null never matches a value; get_dataset_by_id signature unchanged (test) guard: no search/limit (ui#421 item 6); guard: no model column
W0.2 feat(api): admin_coverage on admin-level result GeoJSON + Attica-popup diagnosis PARTIAL — per-feature sums exist (results.py:216-238); no collection figure; _result_to_dict already promotes scope coverage (:245-256) the map can tell how much of a result the regions cover src/climate_lama/api/v1/results.py, src/climate_lama/core/admin_aggregation.py, tests/test_api/test_results_admin_coverage.py, docs/CLIMATE_LAMA.md (hotspot) none · yes · M · build assets on both sides; unclipped assigned sum; disjointness assert; fixture test Σ eai_exp_sum = EAD ± 1 % under full coverage and ratio < 1 under partial (prove-it-fails: drop one polygon); diagnosis box on the owner's prod run (mechanisms i/ii in D6c) → Refs W3.1 when prod is out of reach guard: no UI; guard: no tier logic; defer: the popup defect's fix if the diagnosis names a worker-side cause → a follow-up issue named on W3.1
W0.3 feat(worker): live_event_id/portfolio_id on event job_complete; explicit result_id/batch_id on report_render download_ready PARTIAL — tasks.py:1754,1757 hold both ids above the emit at :1810-1830; report emit :3913-3941 stamps a dynamic subject key notifications name what they are about src/climate_lama/worker/tasks.py (hotspot — after #1293), tests/worker/test_compute_event_impact.py, tests/test_worker/test_render_report.py #1293 · no · S · build both branches of the report emit tested (single result → result_id; matrix → batch_id); event payload test asserts live_event_id present and portfolio_id nullable guard: no new NotificationType; guard: notify=false feed jobs untouched
W0.4 feat(api): ?classed=score tile rendering + bands on RasterLegend MISSING — tiles.py:111-145 accepts bidx/rescale/colormap_name only; RasterLegend has domain/colormap_name (layer_spec.py:64-100); scheme edges at core/scoring.py:793 the Lookup raster shows the card's bands src/climate_lama/api/v1/tiles.py, src/climate_lama/core/tile_render.py, src/climate_lama/core/scoring.py, src/climate_lama/models/layer_spec.py, src/climate_lama/api/v1/hazards.py, tests/test_api/test_tiles_classed.py, tests/test_api/test_hazards.py, docs/CLIMATE_LAMA.md (hotspot) none · yes · M · build first: curl the local titiler 0.19.2 with the interval form, record the answer, fall back to value-keyed edges if refused; classed×colormap_name → E_VALIDATION (test); cache token differs with/without classed (prove-it-fails: drop it from the token and the cache-collision test goes red); a peril with no scheme → unchanged tile + bands: null guard: no UI; guard: no scheme edits; guard: colormap_name behaviour unchanged
W0.5 feat(scripts): load_admin_boundaries --geojson accepts a FeatureCollection PARTIAL — single-Feature mode only (:333-338, :546-558); archive mode global-only prod can hold a country's full admin set scripts/load_admin_boundaries.py (seam), tests/scripts/test_load_admin_boundaries.py, docs/RUNBOOK.md (hotspot) none · yes · S · build a two-feature collection (levels 1 and 2) loads two rows in level order (prove-it-fails: reverse the sort); a Feature still loads; the env gate is not read in this mode (test); seam box: the script runs inside the api image (docker/ COPYs scripts/? — resolve and record) guard: no archive-mode change; guard: no download logic
W0.6 chore(ci): --profile geocoding in deploy-prod.yml, gated on GEOCODER_ADVERTISED=true MISSING — three P= assemblies gate --profile climada on CLIMADA_SIDECAR_URL (:472-475, :594-596, :902-903); no geocoding gate none until the flag flips .github/workflows/deploy-prod.yml (seam), docs/DEPLOYMENT.md, .env.example.prod (seam, comment) none · yes · S · build (Opus — seam floor) all three sites edited (a test or grep count in the PR body: 3); comment + proof lines updated; inert with the flag absent (prove-it-fails: a dry P= echo with and without the key); seam box: resolved values recorded guard: no compose edit; guard: no host action (W3.2)
W0.7 feat(api): indicator on the catalog coverage cell MISSING — core/catalog_coverage.py:82-100 counts dataset_count/computable_count only; HazardDataset rows carry indicator Home can label indicator perils honestly src/climate_lama/core/catalog_coverage.py, src/climate_lama/api/v1/catalog.py, tests/test_api/test_catalog_coverage.py, docs/CLIMATE_LAMA.md (hotspot) none · yes · S · build a cell with only indicator datasets → true; mixed → false (prove-it-fails: flip one fixture row) guard: no change to counts
W1.1 feat(ui): active nav entry — NavLink + aria-current MISSING — App.tsx:136-142 plain Link; App.test.tsx:233 asserts the set the open page is highlighted src/App.tsx (hotspot), src/test/App.test.tsx (hotspot) CortoMaltese3/climate-lama-ui#379 · no · S · build exactly one aria-current="page" on the ten routes listed in D9, zero on /measures /matrix /nope (prove-it-fails: drop end on /) guard: no route changes; guard: no mapping table
W1.2 feat(ui): popups close on outside click, Escape and a reachable × MISSING — two constructors (ResultMap.tsx:526, LiveEventMap.tsx:302) closeOnClick:false; no CSS; background click keeps the popup; ResultMap.test.tsx:121 fresh canvas per call popups behave src/index.css, src/components/ResultMap.tsx, src/components/live/LiveEventMap.tsx (option only), src/test/mapMock.ts (new, the PortfolioMap.test.tsx:18-41 stable-canvas pattern), src/test/ResultMap.test.tsx none · yes · S · build Escape and background click remove the popup (tests against the stable mock; prove-it-fails: restore closeOnClick:false); close button ≥ 28 px hit area (CSS asserted in a vitest by class presence) guard: no other LiveEventMap change; guard: no selection-logic change
W1.3 refactor(ui): one ConfirmDialog EXISTS in src/admin/ConfirmDialog.tsx; native confirm/alert at ConfigPanel.tsx:476,481, ImpactFunctionEditor.tsx:96; KeyActionDialog ApiKeysPage.tsx:186, DeleteDialog RunsPage.tsx:1020 delete/confirm flows look like the app src/components/ConfirmDialog.tsx (moved), src/admin/ConfirmDialog.tsx (re-export or deleted), src/components/ConfigPanel.tsx (hotspot), src/components/ImpactFunctionEditor.tsx, src/auth/ApiKeysPage.tsx, src/pages/RunsPage.tsx, their tests none · yes · M · build grep -rn "window.confirm\|[^a-zA-Z]alert(\|[^a-zA-Z]confirm(" src --include=*.tsx → 0 outside tests (prove-it-fails: leave one); every replaced flow keeps its busy-while-in-flight guard (tests) guard: no copy changes beyond moving strings; guard: ConfigPanel hazard rail untouched beyond the dialog call
W1.4 fix(ui): CostBenefitPanel rail keeps its Run button EXISTS-with-defect — CostBenefitPanel.tsx:133/139/331 vs ConfigPanel.tsx:505/536/1112 the cost-benefit Run button never scrolls away src/components/CostBenefitPanel.tsx, src/test/CostBenefitPanel.test.tsx none · yes · S · build ui#403's test shape on the new footer testid (prove-it-fails: restore overflow-y-auto on the aside) guard: no rail redesign
W2.1 feat(ui): hazard datasets grouped by country; peril labels via hazardLabel PARTIAL — combobox groups (DatasetCombobox.tsx:197-221); hazard pickers pass none (ConfigPanel.tsx:664,685); no optgroup in src/; labels via glossary: at ConfigPanel.tsx:594, HazardUploadModal.tsx:333 Assess hazards grouped like exposures; one casing everywhere src/components/ConfigPanel.tsx (hotspot), src/components/HazardUploadModal.tsx, src/types/index.ts (hotspot), src/test/ConfigPanel.test.tsx, src/test/hazardLabelNamespaces.test.ts CortoMaltese3/climate-lama#, CortoMaltese3/climate-lama-ui#378 · no · S · build groups rendered verbatim from country (prove-it-fails: a row with country:null lands in the ungrouped bucket, not under a derived heading); native <optgroup> at ≤ 8; namespace test: every glossary.hazardTypes.* equals lookup.hazards.* (prove-it-fails: recase one) guard: no client-side derivation; guard: DatasetCombobox unchanged
W2.2 feat(ui): Home fits one viewport EXISTS-with-defect — HomePage.tsx:43-48, RECENT_RUNS_LIMIT=10, PORTFOLIOS_LIMIT=5, home.spec.ts:36-163 no scroll assertion, viewport 1280×800 Home in one look src/pages/HomePage.tsx (hotspot), src/components/home/*.tsx, src/types/index.ts (hotspot), src/i18n/locales/en/home.json (hotspot), tests/e2e/smoke/home.spec.ts, src/test/HomePage.test.tsx (hotspot), src/test/home/*.test.tsx CortoMaltese3/climate-lama#, CortoMaltese3/climate-lama-ui#380 · no · M · build test.use 1440×900 describe; volume floor seeded through the real APIs, fails below it; scrollHeight ≤ clientHeight + 120 with features.live_mode on (prove-it-fails: RECENT_RUNS_LIMIT = 10 → red, recorded); error_code kept + error_message added; indicator chip from the cell flag guard: no card removed; guard: no content change to Coverage; guard: no LiveNowCard edit beyond grid placement
W2.3 feat(ui): notification rows navigate; mark all read in the bell PARTIAL — notificationDeepLink exists, single consumer; bell row onRead only; markAllNotificationsRead at client.ts:1494 a notification opens what it names src/lib/notificationText.ts, src/components/NotificationBell.tsx, src/components/home/NotificationsCard.tsx, src/hooks/useNotifications.ts, src/test/NotificationBell.test.tsx, src/test/notificationText.test.ts CortoMaltese3/climate-lama#, CortoMaltese3/climate-lama-ui#379 · no · S · build one rule per type per D4 (table-driven test over every payload shape incl. portfolio_id:null, matrix report → /assess?batch=, job_failed inline expand); click marks read and navigates (prove-it-fails: drop navigate); Mark all read wired guard: no client.ts edit (method exists); defer: ?job= for job_failed → Out of scope
W2.4 feat(ui): RunProvider — runs survive navigation; header run pill; ?job= re-attach MISSING — Dashboard.tsx:487,1668; no provider in App.tsx:233-247; AuthContext.toast error-only a run keeps running while you browse src/run/RunProvider.tsx, src/run/RunToast.tsx, src/App.tsx (hotspot), src/components/Dashboard.tsx (hotspot), src/components/assess/RunStatusPanel.tsx, src/i18n/locales/en/common.json, src/test/RunProvider.test.tsx, src/test/Dashboard.test.tsx, tests/e2e/smoke/compute.spec.ts CortoMaltese3/climate-lama-ui#379 · no · M · build start → /lookup → /assess shows the same job (smoke; prove-it-fails: revert the provider and the spec goes red); ?job= precedence below batch (unit); pill visible off-route; toast with action; provider clears on logout/org switch (test) guard: result/GeoJSON stay on Assess; guard: AuthContext.toast untouched; guard: no polling-interval change
W2.5 feat(ui): regional tier gated on admin_coverage; merge not replace; admin-unit popup EXISTS-with-defect — ResultMap.tsx:72,551-576,661; Dashboard.tsx:2765-2767 replace zoomed-out results never lose assets src/components/ResultMap.tsx (hotspot), src/components/Dashboard.tsx (hotspot), src/components/LayerPanel.tsx, src/api/client.ts (hotspot), src/types/index.ts (hotspot), src/i18n/locales/en/results.json, src/test/ResultMap.test.tsx, src/test/Dashboard.test.tsx CortoMaltese3/climate-lama#, W1.2, W2.4 · no · M · build ratio < 0.95 → points + note; absent figure → points (prove-it-fails: return polygons with no admin_coverage and assert the point layer is visible); merged payload keeps points after an admin fetch; polygon popup copy; centroid-MVT case asserted guard: no threshold UI; guard: no level-2 tier (defer: → Out of scope)
W2.6 feat(ui): banded hazard legend + zoom fade on Lookup and Datasets PARTIAL — LookupMap.tsx:159-181, DatasetMap.tsx:135, legend.ts:22-52 the raster reads like the card src/components/lookup/LookupMap.tsx, src/components/datasets/DatasetMap.tsx, src/map/legend.ts, src/api/client.ts (hotspot), src/types/index.ts (hotspot), src/i18n/locales/en/lookup.json, src/test/LookupMap.test.tsx, src/test/DatasetMap.test.tsx, src/test/legend.test.ts CortoMaltese3/climate-lama# · no · M · build bands present → swatches with labels, else gradient (test both); fade expression recomputed on slider change (prove-it-fails: fix the anchor at 0.7 and the slider test goes red); classed=score sent only when bands present guard: no card changes; guard: opacity default stays 70 %
W2.7 feat(ui): country filter on the Datasets catalogues MISSING — HazardCatalog.tsx:41,111,179-185 slices by region; ExposureCatalog.tsx:88-100 source + license only Datasets names a dataset's place as Assess does src/components/datasets/HazardCatalog.tsx, src/components/datasets/ExposureCatalog.tsx, src/api/client.ts (hotspot), src/i18n/locales/en/datasets.json, src/test/HazardCatalog.test.tsx, src/test/ExposureCatalog.test.tsx CortoMaltese3/climate-lama#, W2.1 · no · S · build the server filter is sent (?country=), never applied client-side (prove-it-fails: filter locally and the request-assertion test goes red); the region filter stays guard: no ?hazard= selection (defer: → Out of scope)
W3.1 GADM level-1/2 load on prod + W0.2 diagnosis owner-run — prod SSH — — — · ops · model: none per-level counts before/after; diagnosis numbers posted on W0.2's issue; owner re-opens the Greece run —
W3.2 enable geocoding on prod owner-run — .env.prod + host — — — · ops · model: none import wall time/disk/RAM recorded; /v1/geocode?q=Thessaloniki 200; features.geocoding true; Moment 1 step 1 walked with an address —
W4.1 docs: Phase 16 closeout PARTIAL — banner/log written at define time none docs/plan/plan.md (hotspot), docs/plan/showcase-script.md (hotspot), docs/plan/ui-revamp.md, docs/plan/phase-16-consistency.md, CLAUDE.md (hotspot) after every build issue · no · S · build (Sonnet) status log entry; Moment 1 step 1 flipped only if W3.2 walked; Phase 17 stub named consistently guard: no code
W4.2 release + owner re-walk + mid-review owner + orchestrator — docs/plan/mid-review-phase-16.md — · ops · model: none nine points re-walked on prod; rework rate quoted —

Amendment 2026-09-14 — after P16-B1 landed and the Phase 15 R-0′ hygiene pass

State: P16-B1 landed 2026-09-13 (PRs #1400–#1404, trunk gate 34786761479 green at e8f1be9);

1373 and #1374 are closed, **#1369 #1370 #1375 stay open on one box each — "smoke_e2e.py still

runs" (build agents have no Docker), so their code is merged and nothing may treat them as unmet deps (/phase-build resolves an open dep by its merged PR); #1370 also holds the prod diagnosis box, routed to #1376. P16-B2 is in flight** (feat/1372-classed-score-tiles). On the UI side ui#378, ui#379, ui#380 and ui#424 landed (P15-U1), so P16-U2's precondition is met; P16-U1 has not started.

Issue map additions (filed 2026-09-14 by the Phase 15 R-0′ pass from the ui#421 / ui#377 dispositions, each with a parseable ## Execution block, all S · Sonnet · xhigh): backbone #1406 (?search= on GET /v1/hazards; hazards.py, hazard_repository.py, docs/CLIMATE_LAMA.md — hotspot with #1372, so after P16-B2) · #1407 (portfolio_id on JobListItem; separate from

1371's notification payloads) · UI ui#454 (listHazardCatalog passes search + limit;

cross-repo precondition #1406 in prose — client.ts / contract-map.ts hotspot with ui#433 and ui#437) · ui#455 (Intl.NumberFormat cache in formatNumber; independent). #1408 (legacy mirror-attachment weight) is off-milestone, found-in:phase-14. ui#421's two surviving role="alertdialog" sites (DatasetInspector.tsx, PortfoliosPage.tsx) are pointed at ui#429 by comment — ui#429 absorbs them.

Wave plan, amended (unchanged lines above still hold where not restated): - Walk (agent, WSL box): scripts/smoke_walk_wsl.sh --require-merged 1401 --require-merged 1403 --require-merged 1404 — green closes #1369 #1370 #1375 on their last box. Runs any time; not a precondition for any wave. - P16-B2 /phase-build 1371 1372 --no-pr-ci --max-runtime 120 — in flight. - P16-B2′ /phase-build 1406 1407 --no-pr-ci --max-runtime 120 — after P16-B2 (#1406 shares hazards.py, test_hazards.py and docs/CLIMATE_LAMA.md with #1372; #1407 is disjoint). - P16-B3 /phase-build 1378 --no-pr-ci — last, after both repos' build issues and the walk. - P16-U1 /phase-build 428 429 430 455 --no-pr-ci --max-runtime 150 — now (all independent). - P16-U2 /phase-build 427 431 432 --no-pr-ci --max-runtime 200 — now (its preconditions are met). - P16-U3 /phase-build 433 434 437 454 --no-pr-ci --max-runtime 240 — after P16-B2 (#1371) and P16-B2′ (#1406); contract-map.ts / client.ts serialise 433 → 437 → 454. - P16-U4 unchanged — after P16-B2 (#1370, #1372), ui#428 and ui#434.

Shared-trunk rules with Phase 15 (its roadmap v4, PR #1411): the Phase 15 release re-derives the Alembic head and the CHANGELOG at bump time and asks for a freeze of Phase 16 backbone merges between the Phase 15 bump PR and its tag — the Phase 15 session announces the window before it opens; no Phase 16 wave starts a backbone merge inside it. Never pre-assign a migration revision in an issue body — "next free at branch time" is the contract on both phases. Two /phase-build lanes at most on this trunk; a Phase 16 orchestrator hands its builders climate_lama_test_5 / _6 so a concurrent Phase 15 lane's _2–_4 never collide.

Amendment 2026-09-15 — P16-B2 and the walk landed; the Phase 15 release reorders the lanes

State: P16-B2 landed 2026-09-14 (PR #1409 → #1371, PR #1413 → #1372, both Refs). The P16-W walk ran green 2026-09-15 at 5a280e1 (WSL box, --clean-slate, five --require-merged), closing

1369 #1370 #1371 #1372 #1375 on their last box; #1372's interval colormap is now proven against the

ghcr titiler image, not only pip titiler. Two contract corrections from #1413 live as comments on ui#436: score bands ship as RasterLegend.classes (bands stays RasterBandStats[]), and classed=score is refused together with rescale (and with colormap_name) — the UI tile builder omits rescale and colormapName whenever it sends classed. #1371's report half needed no change (the payload already carried literal result_id / batch_id), so ui#433's contract holds. Considered, not filed: memoising classes per (haz_type, data_type, unit) — #1413 conditions it on tile latency nobody has measured; it stays a PR note until a number exists.

Phase 15 release state (roadmap v5, PR #1424 1494aeb): backbone v0.25.0 is tagged at 7ad780c and live on prod (Alembic 0094) — it carries P16-B1, P16-B2 and #1374's geocoding profile, and the backbone freeze is lifted. The UI release is not finished: v0.15.0 was tagged on the stale e9d0c21 (ui#461) and a UI re-release (P15-R′) is pending. Hold Phase 16 UI merges until P15-R′ re-tags — a P16 UI merge before the re-tag rides a release whose CHANGELOG does not declare it, and /phase-build merges on its own. This inverts the 2026-09-14 order: backbone now, UI after the re-tag. The one open owner decision is #1376's GADM name source (JSON strips Greek spaces vs the GeoPackage release), pending since 2026-09-13; it gates the prod load, not any build wave.

Sessions and dependencies, in the Phase 15 form (a ✓ marks a merged dep; /phase-build resolves an open dep by its merged PR). Every backbone lane hands its builders climate_lama_test_5 / _6.

Session What Command Deps Owner Exit
P16-B2′ Hazard search; portfolio_id on job rows /phase-build 1406 1407 --no-pr-ci --max-runtime 120 v0.25.0 tag ✓ (freeze lifted); #1372 ✓ orchestrator ✅ done — #1406 #1407 merged (PR #1436 + SDK fix #1435), gate green at 1efb351
P15-R′ UI re-release on trunk after ui#461 Phase 15 release session (lama-release-P15-R) Phase 15 Phase 15 session + owner ✅ done — UI re-tagged on a trunk sha, prod paired; UI hold lifted
P16-U1 Popups close; one ConfirmDialog; cost-benefit rail; formatNumber cache /phase-build 428 429 430 455 --no-pr-ci --max-runtime 150 P15-R′ re-tag (UI hold); no code deps orchestrator (UI) ✅ done — ui#428 ui#429 ui#430 ui#455 merged
P16-U2 NavLink; hazards grouped by country; one-viewport Home /phase-build 427 431 432 --no-pr-ci --max-runtime 200 P15-R′; ui#379 ✓ ui#378 ✓ ui#380 ✓ #1369 ✓ #1375 ✓ orchestrator (UI) ✅ done — ui#427 ui#431 ui#432 merged
P16-U3 Notification deep links; RunProvider; Datasets country filter; hazard search UI /phase-build 433 434 437 454 --no-pr-ci --max-runtime 240 P15-R′; #1371 ✓; #1406 (B2′); ui#379 ✓; contract-map.ts / client.ts serialise 433 → 437 → 454 orchestrator (UI) ✅ done — ui#433 ui#434 ui#437 ui#454 merged
P16-U4 Coverage-gated choropleth; banded legend + zoom fade /phase-build 435 436 --no-pr-ci --max-runtime 200 #1370 ✓ #1372 ✓; ui#428 (U1); ui#434 (U3); ui#436 applies both correction comments orchestrator (UI) ✅ done — ui#435 ui#436 merged
P16-O1 GADM levels 1–2 on prod; the Attica-popup diagnosis numbers #1376, owner-run v0.25.0 on prod ✓; owner decision on the GADM name source owner ✅ done — #1376 closed 2026-09-16; diagnosis numbers posted
P16-O2 Enable geocoding on prod #1377, owner-run v0.25.0 on prod ✓ (#1374's profile); .env.prod GEOCODER_ADVERTISED=true; nominatim import owner ✅ done — #1377 closed 2026-09-17; Moment 1 step 1 address search walked on prod (Cyprus unresolved, Greece-only PBF)
P16-B3 Closeout docs /phase-build 1378 --no-pr-ci every build issue in both repos merged; O1 + O2 done orchestrator ✅ done — PR #1442, gate 35242713348 green at 3eaaf04
P16-R Phase 16 release; owner re-walk of the nine points; mid-review #1379 B3 owner + orchestrator ✅ done 2026-09-17 — v0.26.0 / UI v0.16.0 on prod, owner walk of all nine points green, mid-review-phase-16.md

Order: P16-B2′ now. P16-U1 the moment P15-R′ re-tags, then U2, then U3 (its client.ts seam serialises the three, and ui#454 needs #1406 merged, which B2′ provides first). U4 after U1 and U3. O1 and O2 are owner-run against the live v0.25.0 and gate nothing but B3. The 3-live-builder cap is machine-wide and shared with Phase 15's remaining lanes (P15-B8, P15-U4); a lane that would be the fourth builder waits.