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-700renders one row per peril fromderiveHazardOptions; the per-peril picker is aDatasetComboboxaboveHAZARD_SEARCH_THRESHOLD = 8datasets (:48) and a native<select>at or below it, and neither passesgroup— the exposure picker does (:348 group: ds.country), rendering the server's derived country verbatim (Phase 14 guardrail 8).ui src/types/index.ts:413HazardDatasethasregionbut nocountry; backboneapi/v1/hazards.py::_dataset_to_dict(:307) emits no country; the derivation already exists asdb/repositories/hazard_repository.py::_hazard_dataset_country_iso3(:54), used only by the coverage query (:1246). ui#421 item 6 records thatGET /v1/hazardshas nosearch/limit-style server filter either. - O2
ui src/pages/HomePage.tsxismax-w-5xlwith a two-columnmd:grid-cols-2grid of five cards, eachp-6;RecentRunsCardreadsGET /v1/jobs?limit=10(RECENT_RUNS_LIMIT = 10, three lines per row incl. the error line),PortfoliosCardlimit=5with a four-line row,CoverageCarda full-width country × peril table. The scroll owner is Home's root (flex-1 overflow-y-auto; the runtime pin istests/e2e/smoke/scroll-contract.spec.ts—tests/scroll_contract.test.tsis a repo-wide source grep). The failed-run row printsError code E_VALIDATION(kept on purpose, backbone #1182) and not the job'serror_message, whichJobListItemcarries too. ui#380 (Phase 15, open) adds a sixth card, Live now, behindfeatures.live_mode. The smoke suite's viewport is 1280×800 (tests/e2e/smoke/playwright.config.ts:72) andhome.spec.tsaccepts an empty Home (:124-145). - O3
ui src/components/NotificationBell.tsx::NotificationRow— the row's only handler isonRead(item.id);src/lib/notificationText.ts::notificationDeepLinkexists and is used by Home'sNotificationsCardonly. Payloads (backbone):job_completecarriesjob_id,job_type(impact_calc|event_impact_calc),result_id,title(worker/tasks.py:1346-1358, 1811-1820) — the event lane'sresult_idis an event result, for which/assess?result=is the wrong destination;job_failedcarriestitle+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:483holdscomputeJobIdin componentState;useJobPoller(state.computeJobId)(:1668) lives in the page;App.tsxmounts<Dashboard />at/assessonly, 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 bystatusandjob_type. - O5 Two halves. (a) Data: prod's
admin_boundariesholds four rows —CYP 0 Cyprus,CYP 1 Larnaca,GRC 0 Greece,GRC 1 Attica(psql on prod, 2026-09-10) — the smoke fixturestests/fixtures/admin_boundaries/gadm41_{GRC,CYP}_{0,1_*}.json.gz, loaded once throughscripts/load_admin_boundaries.py --geojson(single-Featuremode). (b) Contract:ui src/components/ResultMap.tsxswitches to the choropleth tier belowCHOROPLETH_MAX_ZOOM = 8(:72) after a 300 msmoveenddebounce, refetches withadmin_level = 1, andapplyResultTierhides the point layer whenever the answer has any polygon (polygons.length > 0); the admin fetch replacesgeojsoninDashboard(:2760-2770), so the points are gone until the next refetch. Backboneapi/v1/results.py::_build_admin_aggregation_geojson(:188) returns one feature per polygon at that level witheai_exp_sum/point_countand no collection-level coverage figure;core/admin_aggregation.pygroups 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:526andlive/LiveEventMap.tsx:302createnew maplibregl.Popup({ closeButton: true, closeOnClick: false }); MapLibre's default close button is a 16 px glyph;src/index.cssstyles nothing of it;handleBackgroundClickfires the selection callback but never removes the popup. - O7 Prod
GET /v1/geocode→503 E_GEOCODE_UNAVAILABLE reason=connection.docker-compose.prod.yml:86-97forcesGEOCODER_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, healthcheckstart_period45 m) sits behindprofiles: [geocoding];.github/workflows/deploy-prod.ymlappends only--profile climadato its threeP=assemblies (:457-474,:584-595,:896-903) and runsup -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 whenfeatures.geocodingis false. - O8
ui src/App.tsx:129-137rendersNAV_ITEMSas plain<Link>s with one class string; noNavLink, noaria-current. - O9
ui src/components/lookup/LookupMap.tsxdraws 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.tsmaps six ramp names to CSS gradients. The backbone proxy (api/v1/tiles.py:111-145,core/tile_render.py::parse_render_params) acceptsbidx,rescale,colormap_nameonly. Score schemes exist per peril (models/score_scheme.py, ten bands withlower/upperedges, migration 0057) and drive the Lookup card's band — the raster and the card speak different scales. - A1 Native dialogs:
ConfigPanel.tsx:476confirm(...)and:481alert(...)on hazard delete;ImpactFunctionEditor.tsx:96window.confirm. Three hand-rolled dialogs coexist (admin/ConfirmDialog.tsx,auth/ApiKeysPage.tsx::KeyActionDialog,pages/RunsPage.tsx::DeleteDialog) — ui#421 item 4. - A2
CostBenefitPanel.tsxcarries the rail-squash defect ui#403 fixed inConfigPanel(ui#421 item 8: nomin-h-0, noshrink-0on the footer). - A3 Home "Start here" chips print
Wildfire · 7 datasets · 0 computablefor 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#421item 1: the hand-copiedmaplibre-gltest mock returns a fresh canvas per call, so every cursor/hover assertion in ~10 map test files is vacuous — a Phase 16 issue that touchesResultMap.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 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:
- Numbering (D1) — "Consistency" takes 16; the revamp stub moves to 17; the phase runs beside Phase 15 with fully-qualified cross-milestone deps.
- Country on the wire (D2) rather than grouping hazards client-side by
regionorbbox. - One-viewport Home as a measured Playwright assertion (D3), with the row
limits 5 / 3 and a three-column grid at
xl. - Deep links read off payload keys the emitter already has (D4), adding
live_event_idon the backbone rather than a UI lookup;job_failedexpands inline because no page lists jobs. - A
RunProviderabove the routes plus?job=(D5) rather than keepingDashboardmounted or persisting the job inlocalStorage. - 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.
- Enabling geocoding on prod (D8), reversing #1322 on the owner's re-report, with the profile grep-gated in the deploy workflow.
- Classed tiles built server-side from the score scheme (D10) rather than a client-side colormap, a contour layer or removing the raster.
- Promoting
ConfirmDialogapp-wide (D11) as one issue. - Nothing on
/live,/measuresor dark mode — pointers in Out of scope. - The geocoding profile gates on the functional
GEOCODER_ADVERTISED=truerather 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/hazardsrow +="country": "GRC" | null(ISO3 upper,null= not derivable). Querycountry: str | None— upper-cased, matched in SQL against_hazard_dataset_country_iso3();nullrows never match. Repository:list_datasets_with_country(...)/get_dataset_with_country(id)→{"dataset": HazardDataset, "country": str | None};get_dataset_by_iduntouched._dataset_to_dict(ds, *, event_axis, country).- Result GeoJSON at an admin level (
GET /v1/results/{id}/geojson?admin_level=1): collectionproperties+="admin_coverage": {"assigned_points": int, "total_points": int, "ratio": float}(assets both sides; unclipped;ratio0.0 when total 0); featurepropertieskeepadmin_id, admin_name, iso3, admin_level, eai_exp_sum, point_count. Raw-point responses carry noadmin_coverage. - Notification payloads (additive):
job_completewithjob_type == "event_impact_calc"+=live_event_id: str,portfolio_id: str | null;download_readywithjob_type == "report_render"+= exactly one ofresult_id/batch_idunder those names (the dynamic subject key stays for compatibility). - Tile proxy:
GET /v1/tiles/cog/{dataset_id}/{z}/{x}/{y}.png?classed=score— allowed valuescoreonly;classed+colormap_name→E_VALIDATION;RenderParamsgainsclassed, present into_queryandcache_token; the colormap is minted from the peril's active score scheme (five RAG bands from the ten edges; colour hexes = the Lookup card'sriskLevelspalette, copied into the backbone as constants with the source named); a peril without a scheme answers as today andRasterLegendsays so.RasterLegend(models/layer_spec.py, onGET /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 sameindicatorflagHazardDatasetrows carry). scripts/load_admin_boundaries.py --geojson PATH: accepts aFeature(as today) or aFeatureCollection(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 eachP=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 listnominatimwhen the profile is on.- UI URL params:
ASSESS_JOB_PARAM = 'job'on/assess, belowbatch/comparisonin precedence. UI routes unchanged. UI client:listHazardCataloggainscountry?: string;getResultGeoJSONreturnsadmin_coveragein the typed payload;getHazardLayerslegend type gainsbands;listCatalogCoveragecell gainsindicator;useNotificationsgainsmarkAllRead. - UI copy keys (English only, per-surface files):
results.jsonmap.popupAdminAssets("{{count}} assets in {{name}}"),layers.regionalCoverageNote;home.jsonstartHere.indicator,recentRuns.allRuns;notifications.jsonpanel.markAllRead,panel.viewAll;common.jsonrun.running,run.finished,run.open;lookup.jsonlegend.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# |
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# |
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# |
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# |
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# |
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# |
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.