Skip to content

Phase 14 — Solidify

Closed 2026-09-10 — verdict and evidence in mid-review-phase-14.md. Opened 2026-09-05 by owner decision after two months of feature delivery had lowered the product's business value: several surfaces answer no user question, and several that do answer one do not work as intended. Serves Goal B (a backbone that supports the current and next UI without manual patching) and Goal C (climate-lama-ui in a 100 % working state as the showcase). The engine is not touched beyond the adapter.

What it is. A consolidation phase. Every surface is measured against one user question; surfaces that answer none are removed or folded into one that does, and features that exist but fail are fixed at the layer where the fault is — usually the backbone contract the UI consumes. The phase ends when the owner can walk every question on prod without meeting a dead end.

What it is not. No new product functionality. The visual revamp (ui-revamp.md) is Phase 15. Adaptation measures and cost-benefit are Phase 15/16. [SUPERSEDED 2026-09-08: the revamp and the measures are Phase 16 — Phase 15 is Live Risk, see phase-15-live-risk.md D1.] Two named exceptions, and only two: Home is the one new surface, and it is composition only — every card reads an endpoint that exists at open or lands in W0; the single new read endpoint is GET /v1/jobs, the list side of a resource that already exists per id. Completing an existing upload matrix (CLIMADA .h5 files) counts as solidifying an ingress that already advertises "contact admin", not as a feature.

The frame — five questions, five surfaces

Surface User The question it must answer State at open (prod v0.22.0 sha 05cb5c0 per GET /v1/info 2026-09-03; UI v0.12.0)
Home (/, new) everyone "Where do I start, and what happened since I last looked?" no landing page; first login shows an empty map
Assess (/assess, today /) analyst, decision-maker "What is the expected loss for this exposure, now and under a future scenario, and where?" answers "now" only; the future half lives on /matrix; the map shows centroids labelled as assets
Lookup (/lookup) citizen "How risky is this place?" works; layout and raster controls need finishing
Portfolios (/portfolios) insurer "What is my book's risk, where is it concentrated, what would this event cost?" rollup and events work; the asset layer is broken (members missing from the map, dead detail panel, one-point "datasets")
Datasets (/datasets) analyst "What data do I have, what does it cover, is it any good?" a list with a 20-row preview
Runs (/runs, today /scenarios + /compare) everyone "What did I run, can I get it back, how do two runs differ?" bookmarks that lose the result; compare unreachable from its own nav entry

Settings and Admin are support surfaces and are held to "works" only. /matrix, /agreement, /measures and the top-level API-keys entry map to no question and leave the navigation (their capabilities move into Assess and Runs or wait for Phase 15).

Design record (decisions 2026-09-04/05, verified by three /reassess fast passes)

Decision Choice Why (evidence)
D1 Exposure API grain Dataset-first. exposure_datasets becomes the primary resource: list (search, country, type, paging), detail (bbox, row count, value total, unit), rows as a paged, bbox-filterable sub-resource. The per-point GET /v1/exposures stays only as that sub-resource. Every picker is a server-searched combobox over datasets, grouped by country, defaulting to the user's country GET /v1/exposures lists points (api/v1/exposures.py:207-224, default limit=100, newest first); the / dropdown groups those rows by name (ui Dashboard.tsx:306-355) so only the newest dataset appears and "(100)" is the page size; /catalog (exposures.py:227-288) already lists datasets but ExposureDatasetRepository has two methods (create_dataset, get_by_id); prod Greek GHSL datasets hold 216 k rows each (/v1/exposures/catalog row_count, 2026-09-03)
D2 Portfolio membership grain Dataset attachment with a selection rule (whole dataset · bbox/polygon · explicit row list) and the weight on the attachment. An uploaded CSV becomes a dataset attached whole in one call. Compute and the rollup materialise the point set server-side. Existing portfolio_exposures rows migrate to one attachment per dataset with an explicit row list, with a tested downgrade. The unwritable portfolio_assets plane is retired by an ADR that supersedes ADR-047 (wire contract stays additive: member_kind always "exposure", asset_id alias kept, asset_member_count: 0); precondition W0.0: the owner records SELECT count(*) FROM portfolio_assets on prod before W0.5 is dispatched — the last recorded value is 5 (2026-08-02, ADR-047 context), and any rows are demo residue under ADR-077 to delete first; GET /v1/risk/assets/{id} answers 410 a member is one exposures row (models/portfolio.py:40-54); the UI loops one POST per CSV row (ui client.ts:717-727); "Add exposure" attaches one point (PortfolioDetailPage.tsx:392-497); the map joins members against the paged point list and drops the unmatched (:736-758); portfolio_assets has had no writer since scripts/seed_demo.py was deleted (79ebe43, ADR-077; docs/DECISIONS.md:3577); migrations/versions/0026_portfolios.py:67-109 created portfolio_exposures without an RLS policy
D3 Scenario × horizon lives on Assess ImpactRequest gains optional scenario + horizon_year, resolved server-side with the matrix's resolve_matrix_cell_datasets when hazard_dataset_id is absent. Assess mounts ScenarioHorizonControl as a multi-select: one cell → one job → map; several cells → the existing matrix endpoint → a grid strip above the stats bar (EAD per cell, absolute colour scale), click a cell → that cell's map and curve; composed report in the results panel; the saved scenario stores the batch id. Assess reads ?batch=<id> on mount (grid populated, first computed cell selected, URL kept) and Runs links batches there; the redirect /matrix?batchId=X → /assess?batch=X is mandatory (Phase 11–13 evidence URLs point at /matrix). /matrix is deleted (page, route, nav); Moment 3 steps 1, 2, 4 are rewritten to walk on Assess, step 3 saves on Assess and recalls from Runs; tests/e2e/smoke/{matrix,scenario-matrix}.spec.ts migrate and the matrix vitest suites go in the same PR the year slider indexes the chosen dataset's supported_years and freezes on a one-year dataset (ui ConfigPanel.tsx:122-141, YearRangeSlider.tsx:25,55-75); its value is never sent — ImpactRequest has no horizon field (api/v1/compute.py:235-245); MatrixRunPage.tsx:28 is today the only reader of ?batchId=; /matrix = the same three prerequisites + scenario/horizon checkboxes, N identical jobs, EAD-only grid, no map, "Run another" discards the batch (:64-71); owner decision 2026-09-04: "the one question should be answered by /"
D4 Engines on Assess An Engine option (default climate_lama, filtered by the hazard's supported_hazards) plus a Compare engines toggle: off → one job; on → the existing compare endpoint, results panel gains a per-engine table (EAD, AAI, engine_version, divergence badge vs the reference), map shows the reference engine. The comparison id is stored on the saved scenario and Assess reads ?comparison=<id> on mount. /agreement is deleted; Moment 3 step 6 is rewritten onto Assess; tests/e2e/smoke/agreement.spec.ts migrates and the agreement vitest suites go /agreement = same prerequisites + EnginePicker, output EAD/AAI/insured_loss + badges (ui components/agreement/AgreementView.tsx:9-19); comparisons are excluded from the batch list (api/v1/compute.py:606-609) and lost on reload (EngineAgreementPage.tsx:87-94); engine_version fix is live code (UI trunk e65b0c1 = PR #301) and must carry over; owner decision 2026-09-05
D5 Home dashboard / becomes Home, data not launcher: Start here (the user's country data, one click into Assess with the country preselected, one into Lookup) · Recent runs (GET /v1/jobs, failed and stuck included) · Portfolios (GET /v1/portfolios/summary, top books by exposed value and EAD) · Data coverage (GET /v1/catalog/coverage, country × peril grid) · Notifications (unread, deep-linked). Assess moves to /assess; the /dashboard redirect retargets to /assess. Every card has an empty state and no card gets a backend built for it beyond the W0 reads named here first login shows an empty map; the riskwise-v2 home was a launcher + status column (ui-revamp.md screenshot 01) — the status half is the part worth keeping; today there is no cross-portfolio ranking (api/v1/portfolios.py:53,83,182) and no coverage endpoint (api/v1/ has no catalog module); owner decision 2026-09-05 (data, not launcher; in this phase)
D6 Runs hub /runs lists saved scenarios, matrix batches and engine comparisons in one place. Save persists the full input set (exposure dataset_id, hazard id, impact function id, scenario label, horizon, engine, measure ids, batch/comparison id); open loads result_id (no re-run) and keeps ?scenario= in the URL; rename and notes via the existing PATCH; batch rows show dataset names and can be deleted; Compare gets a two-scenario picker in its empty state and keeps its nav path inside Runs. Backbone: inputs stays open-shape; a typed ScenarioInputs(extra="allow") is parsed on read into inputs_resolved (names + computability) with warnings[], never a 422; hazard_dataset_id becomes optional when scenario_id is given; the batch list stops excluding comparisons and tags each row with kind save writes three keys (ui Dashboard.tsx:500-505); open nulls the result although result_id is at hand (:171-175), never restores the impact function, strips the param (:184); prod has 0 saved scenarios (GET /v1/scenarios 2026-09-03); /compare header link carries no a/b (App.tsx:104; ScenarioComparePage.tsx:293-299); schemas/scenario.py:1-8 documents the open shape on purpose
D7 Failure reaches the user JOB_FAILED notification type (ADR-037 registry entry + CHECK-constraint migration), emitted from JobRepository.mark_failed() / IngestJobRepository.mark_failed() so every layer inherits it; a guard test asserts zero assignments of JobStatus.FAILED outside db/repositories/ and models/enums.py; jobs.error_code (nullable, reusing core.errors.Code); GET /v1/jobs?status=&job_type= org-scoped; UI poller gets an elapsed budget and consumes its error; failed state shows code, message, job id, retry; results carry rows_computed / scope_total and the stats bar shows "N of M assets" NotificationType locked to five values (models/enums.py:359-374); 16 JobStatus.FAILED sites across 7 worker files (worker/tasks.py ×5, worker/ingest/{aggregate_and_commit ×2, plan_chunks, stage_source, state_machine ×2, watchdog}.py, worker/job_watchdog.py) plus 6 outside the worker (db/repositories/{job,ingest_job}_repository.py, core/{compute_batch_service,engine_comparison_service}.py, api/v1/{results,live_events}.py) and none emits; jobs has no error_code (models/job.py:47); api/v1/jobs.py:30 has only /{job_id}; ui usePoller.ts:44-61 has no budget and Dashboard.tsx:387 ignores error; worker/tasks.py:473 re-checks coverage once but the fallback reload at :490-491 is only tested for emptiness (:492-499) and _ExposureRow.total_scope_members/assigned_in_scope_members (:190-191) are discarded
D8 Notifications POST /v1/users/me/notifications/read-all; job_complete/job_failed payloads gain a title; rows deep-link to the result or portfolio prod: 75 notifications, 69 unread, 57 job_complete (2026-09-03); the bell renders a body only for system_announcement (ui NotificationBell.tsx:19-22, reading payload.message ?? payload.title, so a title is consumable) and click goes nowhere (:50-53)
D9 Measures Hidden, not removed: the cost-benefit rail and the MEASURES section render only when measures exist; the nav entry goes; /measures route stays. Phase 15 charter must fix the adapter's future-hazard flaw first prod has 0 measures; no create UI (ui client.ts:657-670); engine_adapter.py:314-317 passes the present hazard as the future one
D10 Upload matrix Exposures: CSV, CLIMADA entity .xlsx (already accepted, UI hides it), CLIMADA Exposures .h5. Hazards: GeoTIFF, CLIMADA Hazard .h5 in exactly two shapes — (a) a return-period set (positive, strictly monotonic frequency; bands = events, return_periods = 1/f as floats) through the unchanged mode-1 path, or (b) exactly one event, through the Phase 13 single-event declaration with valid_at from date[0]. Anything else (a dated multi-event catalogue, fraction ≠ 1, non-Point geometry, irregular grid, undeclared or non-4326 CRS, unknown units, haz_type mismatch) is refused with the reason. Two guards, env-overridable: hazard_h5_max_events (default 100, the largest band count already served) and n_events × n_centroids ≤ hazard_ingest_max_grid_cells [the product form is SUPERSEDED by #1225 — shipped as n_centroids ≤ hazard_ingest_max_grid_cells, matching what that setting bounds on the GeoTIFF and NetCDF paths], both from intensity/shape at upload and re-checked in the worker. The reader is h5py-only: never pandas.read_hdf, never pickle; it supports the legacy layout (latitude/longitude columns, centroids/lat|lon) and the CLIMADA 5 layout (WKB geometry column decoded with shapely.wkb, centroids store with geometry.x/y); the CRS is a form field because CLIMADA stores it in a pickled attribute. Format dispatch is on the HDF5 magic bytes. Uploads stream to disk in chunks with cap and quota enforced incrementally; the quota is pre-charged at n_events × n_centroids × 4 and corrected to the stored COG size. h5py enters both images via the packs extra (already in uv.lock). Stochastic event sets are the Phase 15 event-set track _ALLOWED_SUFFIXES = {".csv", ".xlsx"} with "HDF5/GeoTIFF are deferred" (api/v1/exposures.py:44-46); scripts/packs/litpop_exposure.py:683-760 reads a LitPop HDFStore with raw h5py and skips the pickled block; CLIMADA develop writes exposures as WKB (Exposures.write_hdf5 → to_wkb(), wkb_columns) and centroids as a pandas store with the CRS in attrs.metadata (fetched 2026-09-05, not verified against a file); validate_source.py:388-394 + aggregate_and_commit.py:885-899 + uq_hazard_events_dataset_single_field admit only RP sets or one unlabelled event; a 100-band heatwave COG is served today (phase-9-full-catalog-walkthrough.md:263,354); hazard_ingest_max_grid_cells exists (config.py:271); upload_hazard buffers the whole file (hazards.py:670-699, cap 2 GiB, 413) and the exposure cap is a hard-coded 50 MB with no per-user quota (exposures.py:47,171); the upload params never set source_format (hazards.py:708-724, override read at worker/tasks.py:2143); owner decision 2026-09-05
D11 Result map Zoom-tiered: the backbone's unused ?admin_level= choropleth zoomed out, centroids sized by point_count zoomed in; absolute colour bands; zero-damage cells in grey; honest popup ("N assets in this cell"); fit-bounds on completion; the dormant martin/MVT branch is deleted one feature per exposure-bearing centroid (api/v1/results.py:148-167); admin_level implemented (results.py:338,360,177-218) but unused (ui client.ts:524); client drops eai_exp<=0 (ResultMap.tsx:195); relative colours (riskLevels.ts:9-17); grep -i martin docker-compose.yml → nothing
D12 Admin + contracts Parse the {items, total} envelope on users/orgs/catalog/pending-ingests; app-level error boundary; audit client rewritten onto GET /v1/audit-events (limit/offset, {data,total}); stub tabs removed; admin catch-all; checkError reads the message_en envelope; a UI contract spec runs every client method against the real backbone envelope in e2e-smoke (its compose extends the backbone's services, tests/e2e/smoke/compose.yml:4,107-109) owner's console: Uncaught TypeError: x.find is not a function in the admin chunk — listAdminUsers is typed AdminUser[] (ui client.ts:961) while the backbone returns {"items": …, "total": …} (api/v1/admin/management.py:168,370); no ErrorBoundary in src/; client.ts:1036 calls /admin/audit, which does not exist (api/v1/auth.py:75 prefix /audit-events; prod 404); same bug class as ui#299
D13 Navigation Home · Assess · Lookup · Portfolios · Datasets · Runs · Settings (API keys inside) · Admin (role-gated) = 8. Removed: Measures, Matrix Run, Engine Agreement, Compare (now inside Runs), top-level API Keys. Consistent with Phase 11's rule (every surface reachable to every logged-in user; Admin was already isAdmin-gated, App.tsx:139) 12 entries today (ui App.tsx:74-141)
D14 Docs move with the code Every PR that deletes or moves a surface rewrites the showcase step, the e2e spec, the vitest suite and the i18n namespace it owned in the same PR (matrix.json, measures.json, wizard.json are orphaned otherwise; i18n/index.ts:48 is a literal ns list — a deleted file breaks the app; i18n.test.tsx:43 hard-codes a second list) Phase 11/12/13 evidence in showcase-script.md is recorded against /matrix and /agreement
D15 Circuit-breaker Redefined, not suspended. PR typing rule: planned Phase 14 PRs are refactor(scope): or feat(scope): (a capability moved or completed), never fix:; fix: is reserved for corrections to code landed inside Phase 14. Rework = attributed fix: PRs ÷ all typed Phase 14 PRs, measured with python scripts/rework_rate.py --since 2026-09-05 --window-hours 168; fires at > 25 %; action unchanged (stop dispatch, run the mid-review) the framework's 50 % rule counts fix: against feat:; scripts/rework_rate.py:37-38 attributes fix( PRs only to non-fix PRs inside its window, so an all-fix: phase would be unmeasurable

Guardrails — every Phase 14 issue

  1. No new product functionality. An issue adds a capability only if it already exists at another layer or surface and is being moved, completed or made to work; the test is naming that surface or endpoint. Named exceptions, closed list: Home (D5, composition of existing reads) and the W0 read endpoints that feed it (GET /v1/jobs, GET /v1/catalog/coverage, GET /v1/portfolios/summary — list or aggregate sides of resources that already exist per id).
  2. Nothing is removed without its walk. A PR that deletes a route also rewrites the showcase step, migrates or deletes the e2e spec and the vitest suite, and deletes the i18n namespace. npm test + npm run test:e2e:smoke green at the merged SHA.
  3. Every job outcome reaches the user. No code path may set JobStatus.FAILED except the repositories' mark_failed, which emits JOB_FAILED; no poller runs unbounded.
  4. Contracts are tested, not assumed. Every UI client method is exercised against the real backbone envelope in e2e-smoke before the phase closes.
  5. Refuse, don't approximate (upload path): irregular grids, unknown units, over-cap event sets and unsupported hazard types are refused with a machine code and a reason — file-shape faults on the endpoint's existing 400 INVALID_FILE_TYPE family, cap and quota faults 413 (as hazard_upload_max_file_bytes today, hazards.py:672-677), declared-field faults 422 — never a silent resample, truncation or default.
  6. ADR-077 holds on prod: no demo data anywhere; residue is deleted by the owner, not hidden by a filter.
  7. Migration PRs are gated (migrations-gate.yml); typed Alembic ops only; each migration PR lands alone and early in its wave; a data migration ships a tested downgrade through tests.conftest.migration_window; new org-scoped tables carry org_id + FORCE RLS with migration 0082's predicate and join tests/db/test_app_role_rls_isolation.py::_ORPHANED_ORG_SCOPED_TABLES.
  8. Dependencies flow downward (architecture rule 9): the UI consumes the new contracts; it never re-implements dataset grouping, coverage or resolution client-side.
  9. PR typing per D15: refactor:/feat: for planned work, fix: only for rework of Phase 14 code.

Scope — waves and items

Dependency line: W0a → W0b → W0c → {W1, W2, W3} → W4 → W5. W0a = {W0.0, W0.1, W0.2, W0.3, W0.10, W0.11, W0.12, W0.13, W0.14, W0.15}; W0b = {W0.4a → W0.4b → W0.4c, W0.8, W0.9}; W0c = {W0.5, W0.6, W0.7}. Inside W1: W1.1 → W1.2 → {W1.3, W1.4a → W1.4b → W1.4c} → W1.5. Every UI row depends on the UI foundation issue W0-UI.0, which lands after the backbone contracts it types. Sizes are drafts; /phase-split grounds and re-sizes each item. UI items live in climate-lama-ui under the same milestone.

W0 — Backbone contracts

Item Delta (grounding) Acceptance
W0.0 Owner precondition (not a build issue) portfolio_assets last recorded at 5 rows on prod (ADR-047 context) SELECT count(*) FROM portfolio_assets recorded on prod; if > 0 the rows are ADR-077 residue and are deleted by the owner, count re-recorded; the value is pasted into the W0.5 issue
W0.1 JOB_FAILED + error_code (migration, lands alone) models/enums.py:359-374 five-value CHECK; models/job.py:47 no error_code; 16 worker + 6 non-worker FAILED sites (D7) ADR-037 registry entry; migration adds job_failed to the CHECK and jobs.error_code (Text, nullable); JobRepository.mark_failed(job, code, message) / IngestJobRepository.mark_failed(...) emit JOB_FAILED with payload {job_id, job_type, error_code, error_message, title}; every writer routed through them; guard test: zero status = JobStatus.FAILED assignments outside db/repositories/ and models/enums.py; GET /v1/jobs/{id} returns error_code; new Code members ship with the SDK mirror (sdk-regenerate.yml)
W0.2 GET /v1/jobs list api/v1/jobs.py:30 has only /{job_id} org-scoped, filters status, job_type, paging; used by Home and Runs
W0.3 Dataset-first exposure API (D1) ExposureDatasetRepository = create + get (exposure_dataset_repository.py:23,39); /catalog exists (exposures.py:227-288) GET /v1/exposure-datasets (search, country, exposure_type, paging), GET …/{id} (bbox, row_count, value_total, value_unit), GET …/{id}/rows?bbox=&limit=&offset=&sort= (rows carry a per-row name from properties, falling back to dataset_name + row index — closes #1156's class); /exposures and /exposures/catalog documented as legacy aliases in docs/CLIMATE_LAMA.md; dataset_id filter on the point list
W0.4a Membership migration (D2, lands alone) portfolio_exposures(portfolio_id, exposure_id, weight) (models/portfolio.py:37-60); 0026_portfolios.py:67-109 no RLS on it portfolio_datasets(id, org_id, portfolio_id, dataset_id, selection{kind: all\|bbox\|rows, …}, weight) with FORCE RLS (0082 predicate) and added to _ORPHANED_ORG_SCOPED_TABLES; data migration folds existing rows into one rows attachment per dataset; downgrade() reconstructs portfolio_exposures from rows attachments and refuses with a clear error for all/bbox ones, tested through migration_window; DB test: an existing portfolio's member set is identical before and after
W0.4b Membership API api/v1/portfolios.py:133,162,214 single-point add/remove/list POST /v1/portfolios/{id}/datasets (rejects a dataset_id outside the caller's org, test), DELETE …/datasets/{dataset_id}, GET …/members?limit=&offset= returning exposure_id, dataset_id, dataset_name, name, lat, lon, value, value_unit, weight; CSV upload = create dataset + one attach; old /exposures routes kept as deprecated aliases for one release
W0.4c Membership read path worker/tasks.py:245-288 membership SQL; core/rollup_service.py unions two planes compute and rollup materialise the point set from attachments (whole / bbox / rows); acceptance = an existing portfolio's compute and rollup return the same numbers as before the migration (test on a fixture book)
W0.5 ADR superseding ADR-047 — retire portfolio_assets ADR-047 accepted (docs/DECISIONS.md:3564-3620); core/rollup_service.py:44,55,467; api/v1/risk.py:206 Deps: W0.0 count = 0, W0.4c. ADR quoting the recorded count; rollup reads attachments only; GET /v1/risk/assets/{id} → 410 with the ADR link; member_kind stays "exposure", asset_id alias and asset_member_count: 0 kept; physical DROP deferred to Phase 15
W0.6 Per-exposure results + latest per portfolio eai_exp per point stored in MinIO (models/result.py:12-18,48-52), read only per centroid (api/v1/results.py:88-92); no /portfolios/{id}/results GET /v1/results/{id}/per-exposure?limit=&offset=&sort=eai (exposure_id, name, lat, lon, value, eai, dataset_name); GET /v1/portfolios/{id}/results?limit= (latest N with portfolio_id)
W0.7 Scope coverage on results worker/tasks.py:473 re-checks once; the fallback reload at :490-491 is tested only for emptiness (:492-499); _ExposureRow.total_scope_members/assigned_in_scope_members (:190-191) discarded after the fallback reload persist rows_computed = len(exp_rows) and scope_total = exp_rows[0].total_scope_members or len(exp_rows) in result metadata; both on GET /v1/results/{id} (results.py:258); DB test with a partially assigned scope asserts rows_computed < scope_total and the job completes
W0.8 ImpactRequest.scenario + horizon_year (D3) api/v1/compute.py:235-245; resolver at core/compute_batch_service.py:379 optional fields; hazard_dataset_id optional when both are present; resolution error → 422 with candidates; unchanged behaviour when absent
W0.9 Scenario contract (D6) schemas/scenario.py:19-26 open dict; ImpactRequest.scenario_id links but does not populate; batch list excludes comparisons (compute.py:606-609) ScenarioInputs(extra="allow"); inputs_resolved + warnings[] on GET /v1/scenarios/{id}; hazard_dataset_id optional when scenario_id given; batch list includes engine_comparison rows with kind
W0.10 Notifications read-all + title (D8) api/v1/notifications.py:42,70,91 three routes; emit sites worker/tasks.py:1210,1651,2524, worker/ingest/watchdog.py:162 POST /v1/users/me/notifications/read-all → {count}; title in job_complete payloads (job_failed gets it in W0.1)
W0.11 Hazard upload supported_years api/v1/hazards.py:591-600 no year field; NULL = wildcard (hazard_repository.py:328-345) optional supported_years form field; stored; test that an uploaded dataset with a year no longer matches other horizons
W0.12 Small contract fixes /orgs/me/branding 404 when no row (api/v1/preferences.py:127-133, by design); #1155 tile proxy 502 /orgs/me/branding → 200 + defaults (the {org_id} variant keeps its 404); #1155 closed
W0.13 GET /v1/catalog/coverage no coverage endpoint; api/v1/ has no catalog module org-scoped country × hazard-type grid (dataset counts, latest ingest per cell) assembled server-side from the hazard and exposure catalogs; a country with no dataset returns an explicit zero cell
W0.14 GET /v1/portfolios/summary api/v1/portfolios.py:53,83,182 list / detail / per-portfolio aggregate only ?limit=&sort=exposed_value\|ead → one row per portfolio with exposed_value, ead, member_count, latest_result_id from stored rollup/results, one query, no N+1
W0.15 Python SDK follows the contracts sdk/python/climate_lama_client/client.py:266,280 exposures.list/catalog; :333 ExposuresAPI.upload; :663 risk.asset; aio.py mirror exposure_datasets.list/get/rows, portfolios.members/attach, jobs.list, notifications.read_all; risk.asset raises a typed error mapping the 410; exposures.list/catalog marked legacy; version bump; sdk-smoke still green

W0-UI.0 — UI client foundation (first UI issue)

Item Delta (grounding) Acceptance
W0-UI.0 client.ts + types/index.ts foundation both files are hand-written and shared by every page (ui src/api/client.ts, src/types/index.ts) new client methods and types for exposure-datasets, portfolio members/attachments, jobs list, per-exposure results, portfolios summary, catalog coverage, notifications read-all, ImpactRequest.scenario/horizon_year, inputs_resolved; paginated {items,total} envelope parsed once; checkError reads message_en; no callers changed; Deps: the backbone issues it types (fully qualified CortoMaltese3/climate-lama#N)

W1 — Upload matrix (D10) — parallel with W2/W3

Item Delta (grounding) Acceptance
W1.1 h5py in both images + streaming uploads packs extra = h5py only, already resolved in uv.lock:719-720,1173; docker/core.Dockerfile:43, worker.Dockerfile:41; both upload handlers await file.read() whole files (hazards.py:670, exposures.py:171); exposure cap hard-coded (exposures.py:47), no exposure quota --extra packs on both uv export lines, image smoke imports h5py; both handlers stream to the staging path in ~8 MiB chunks enforcing the per-file cap and the per-user quota incrementally (413 as soon as the running total crosses); exposure_upload_max_file_bytes setting replaces the literal; exposures get the same quota call hazards use (storage_used_bytes_for_user)
W1.2 CLIMADA HDF5 reader (core/ingest/climada_hdf5.py) LitPop reader at scripts/packs/litpop_exposure.py:683-760 (h5py only, skips block1); no WKB or centroids-store support one module, h5py only, pickle/read_hdf/HDFStore forbidden by a test that greps the module; exposures: coordinates from latitude/longitude columns (legacy) else from the WKB column named in the storer's wkb_columns metadata decoded with shapely.wkb (Point only); hazards: centroids/lat|lon (legacy) else the centroids store's geometry.x/geometry.y; crs form field, default EPSG:4326, any other value refused; dispatch on the HDF5 magic bytes, suffix/Content-Type only a pre-filter; the pack CLI becomes a thin caller; a poisoned fixture (pickled object block, non-Point geometry) is refused without executing anything
W1.3 Exposure .h5 upload + SDK api/v1/exposures.py:44-46,158-162; sdk client.py:333 ExposuresAPI.upload (+ aio.py) .h5/.hdf5 accepted on POST /v1/exposures; value_unit/exposure_type/crs from the form; region_id/impf_* kept in properties; SDK upload gains the fields and suffixes, regenerated + version bump in the same PR; vendored ≤100 KB fixture generated once inside the CLIMADA parity sidecar (tests/parity/Dockerfile.climada-sidecar) with the CLIMADA version and the generating snippet in its README — never an h5py-synthesised stand-in
W1.4a Hazard .h5 adapter worker/ingest/adapters.py:310-313 registry = GeoTIFF + NetCDF; validate_source.py:388-394; aggregate_and_commit.py:885-899 ClimadaHDF5IngestAdapter registered; shape (a) RP set → return_periods = [1/f] floats, one band per RP, mode-1 path; shape (b) one event → single-band + single_event={label, valid_at}; fraction absent or all 1.0 else refused; regular-grid tolerance check; hazard_h5_max_events (default 100) + n_events × n_centroids ≤ hazard_ingest_max_grid_cells re-checked in the worker [SUPERSEDED by #1225 — the product form made the 100-event ceiling dead at production scale (effective ceiling ~9); the shipped guard is n_centroids ≤ hazard_ingest_max_grid_cells, one ceiling per axis]; acceptance = the sidecar-generated RP fixture ingests through the existing chord
W1.4b Hazard .h5 endpoint (lands alone, gated) hazards.py:595-597 return_periods required; :708-712 no source_format (read at worker/tasks.py:2143) return_periods optional (400 when absent for a GeoTIFF); params["source_format"]="climada_hdf5" set by upload_hazard and /hazards/ingest; upload-time guards from intensity/shape after staging (metadata reads only); quota pre-charged at n_events × n_centroids × 4, file_size_bytes corrected to the COG after commit [SUPERSEDED by #1228 — the pre-charge is admission-only; file_size_bytes holds the uploaded source's byte count, stamped once at row creation, and is never corrected after commit]; statuses per guardrail 5; new Code members ship with the SDK errors.py mirror in the same PR (sdk-regenerate.yml is a real PR gate)
W1.4c Docs docs/DATA_SOURCES.*, docs/CLIMATE_LAMA.md accepted shapes, refusals, cap and guards documented; operator paths (GeoTIFF exposure, NetCDF) listed as operator-only
W1.5 UI upload dialogs ui components/datasets/ExposureUploadModal.tsx:13,138 (.csv,.xlsx); HazardUploadModal.tsx:46,69,94,189 (return periods prefilled and required, suffix literals in two places) both accept the new suffixes in their guard and their accept attribute; exposure_type/value_unit/crs fields; return periods hidden for .h5; cap, grid rule and accepted shapes printed; year field (W0.11). components/UploadModal.tsx is deleted by W3.1b, not updated here

W2 — Assess (D3, D4, D7, D11) — UI, parallel with W1/W3

Item Delta (grounding) Acceptance
W2.1 Dataset picker (D1 consumer) Dashboard.tsx:306-355 groups points by name; select keyed by name (ConfigPanel.tsx:212-214) searchable combobox over GET /v1/exposure-datasets, grouped by country, value = dataset_id; state holds exposureDatasetId
W2.2 Reset + sticky deselect Dashboard.tsx:363-367 auto-default; no reset Reset clears all selections and the result; auto-default on first load only
W2.3a Scenario × horizon control, single cell YearRangeSlider inert (ConfigPanel.tsx:122-126) ScenarioHorizonControl multi-select mounted; one cell → POST /compute/impact with scenario/horizon_year, the year reaches the request (test), map renders; YearRangeSlider removed from Assess
W2.3b Multi-cell run + grid matrix lives on its own page several cells → matrix endpoint; grid strip with absolute colours; cell click loads that cell's result and curve; composed report button; Assess reads ?batch=<id> on mount
W2.3c Delete /matrix (lands last in W2) App.tsx:116; i18n/index.ts:12,30,48; tests/e2e/smoke/{matrix,scenario-matrix}.spec.ts; src/test/{MatrixRunPage,MatrixResultsEntry}.test.tsx, matrixClient.test.ts page, route and nav deleted; mandatory redirect /matrix?batchId=X → /assess?batch=X; matrix.json folded into the Assess namespace; specs migrated onto Assess, vitest suites deleted; showcase Moment 3 steps 1–4 rewritten; npm test + npm run test:e2e:smoke green
W2.4 Engine option + compare (D4) EngineAgreementPage.tsx:87-94; components/agreement/*; tests/e2e/smoke/agreement.spec.ts; src/test/{AgreementView,EngineAgreementPage}.test.tsx Engine select filtered by supported_hazards; Compare toggle → compare endpoint → per-engine table incl. engine_version; Assess reads ?comparison=<id>; /agreement deleted, spec migrated, suites deleted, Moment 3 step 6 rewritten
W2.5 Bounded poller + failed state (D7 consumer) usePoller.ts:44-61; Dashboard.tsx:386-387 Deps: W0.1, W0.7. Elapsed budget → "still running, see Runs"; error consumed; failed state shows error_code, message, job id, Retry; "N of M assets" in the stats bar
W2.6 Result map (D11) ResultMap.tsx:25-31,187-208, riskLevels.ts zoom tiers (admin choropleth / centroids by point_count), absolute bands, grey zero cells, honest popup, fit-bounds, MVT branch deleted; EVENTS stat fixed (impact_per_event is a dict)
W2.7 Rails + measures hidden (D9) ConfigPanel.tsx:191-196; CostBenefitPanel.tsx:133 collapsible left rail (persisted); CB rail + MEASURES section only when measures exist; licence text into a tooltip
W2.8 Save/open round-trip (D6 consumer) Dashboard.tsx:500-506,171-176,183-185 Deps: W0.9. Full input set saved; open loads the result, keeps ?scenario=, restores impact function and engine

W3 — Portfolios, Lookup, Datasets — parallel with W1/W2

Item Delta (grounding) Acceptance
W3.1a Members + map PortfolioDetailPage.tsx:673-678 triple fetch, :736-742 dropping join Deps: W0.4b. Members from GET /v1/portfolios/{id}/members; listExposures join removed; every member drawn; colour from the rollup's worst_members (top-10) until a compute exists
W3.1b Add assets + CSV AddExposureRow (:392-497), client.ts:717-727 per-row POST loop, components/UploadModal.tsx Deps: W0.3, W0.4b. "Add assets" = dataset picker + whole / bbox / rows; CSV upload reuses ExposureUploadModal and becomes one attach; AddExposureRow and UploadModal.tsx deleted with their tests
W3.1c Results + polish AssetDetailPanel.tsx:62-70 dead endpoint; PortfolioComputePanel.tsx:76-78 empty on mount; HazardChip Fragment (:84-105); PortfoliosPage.tsx:229-233,357-361 Deps: W0.6. Ranked member table from /per-exposure, members coloured by EAI; last result on revisit via /portfolios/{id}/results; dead detail tabs removed; chips wrapped per hazard with a capped select; list-page EAD sort fixed and Scenario column removed
W3.2 Lookup finishing RiskLookupPage.tsx:476-497, HazardDetail.tsx:120-305, LookupMap.tsx:185, lib/lookupSidebarWidth.ts:13 collapse toggle (persisted); per-section collapse; raster picker independent of the accordion; opacity slider; slice.available_years shown
W3.3 Dataset inspector DatasetPreview.tsx count/bbox/20 rows; unused getHazard, /layers, deleteHazard, quota Deps: W0.3. Map with legend and tiles, coverage bbox, metadata + provenance, paged and filterable rows, delete, quota, auto-select after upload, "Use in Assess"; split-candidate if /analyze sizes it L

W4 — Home, Runs, navigation, admin (D5, D6, D12, D13)

Item Delta (grounding) Acceptance
W4.1a Home shell + Start here + Recent runs none exists Deps: W0.2, W0.13. Route /, Assess at /assess, /dashboard → /assess; Start-here card from coverage + country default; Recent runs from GET /v1/jobs with failed/stuck states; empty states
W4.1b Home cards: Portfolios, Data coverage, Notifications no cross-portfolio ranking, no coverage endpoint Deps: W0.13, W0.14, W0.10. Three cards, each with an empty state and a link into its surface
W4.2 Runs hub MyScenariosPage.tsx, ScenarioComparePage.tsx; tests/e2e/smoke/{compare,save}.spec.ts Deps: W0.9. Scenarios + batches + comparisons in one list with kind; rename/notes; batch delete; dataset names; compare picker in the empty state; /scenarios and /compare redirect into /runs; specs updated
W4.3 Navigation + routes + spec retargeting App.tsx:74-141; specs that goto('/') or a deleted route: compute.spec.ts:7, save.spec.ts:9, upload.spec.ts:24, responsive.spec.ts:166,266, focus/focus-trap.spec.ts:32, focus/page-inert.spec.ts:23,52, scroll-contract.spec.ts:42-48 (DOCUMENT_ROUTES), scenario-matrix.spec.ts:93-95 (Compare nav) eight entries per D13; API keys as a Settings tab; shared back component on nested routes; every listed spec retargeted (/ → /assess, /scenarios → /runs, /matrix//agreement removed from DOCUMENT_ROUTES); npm run test:e2e:smoke green
W4.4 Admin console works (D12) client.ts:961,987,1036; no error boundary; AdminLayout.tsx:10-31,84-96 envelope parsed on four lists; audit onto /audit-events with limit/offset and {data,total}; stubs removed; catch-all; app-level error boundary
W4.5 UI contract spec none among the 15 smoke specs one spec per client method against the real backbone in e2e-smoke; red on an envelope change (prove-it-fails box)
W4.6 Dead code + i18n Wizard.tsx, steps/* (extract StatCard used by PortfolioComputePanel.tsx:17), wizard.json, orphaned measures.json; src/test/{Wizard,MeasuresPage}.test.tsx; i18n/index.ts:48, i18n.test.tsx:43 deleted; both ns lists updated; measures strings kept only where the hidden surfaces still render

W5 — Closeout

Item Delta Acceptance
W5.1 Prod residue (owner-run) "Attica demo portfolio", "Daniel demo portfolio", exposure dataset "walk"; sdk/python/examples/orsa_matrix.py:113-117 deleted; the example sentence rewritten
W5.2 Smoke additions scripts/smoke_e2e.py:1685-1711 already asserts result_id linkage Deps: W0.3, W0.4b, W0.9. Three API assertions: every ingested dataset_id in GET /v1/exposure-datasets; /members returns lat/lon/value for every member; a saved scenario's result_id resolves; each goes red when its behaviour is inverted
W5.3 Docs plan.md, showcase-script.md, CLAUDE.md MVP slice, CLIMATE_LAMA_UI.md, docs/CLIMATE_LAMA.md Moment 3 rewritten onto Assess/Runs; nav in the MVP slice; status log; route and API docs current
W5.4 Releases + owner walk + mid-review — pg_dump before each release (two migrations ship); mid-phase release after W0+W1, final at close; owner walks the five questions on prod; docs/plan/mid-review-phase-14.md

Issues (split 2026-09-05 — /phase-split per repo, sized by /analyze)

Milestone "Phase 14 -- Solidify" in both repos (backbone #14, UI #5), label milestone:phase-14. The split corrected two facts above: JobStatus.FAILED has 12 assignment sites (11 in six worker files + db/repositories/job_repository.py:152); the other hits are read-side comparisons and a frozenset — the guard test is AST-based on assignments. And the backbone already accepts an X-Engine request header on POST /v1/compute/impact and /impact/matrix (api/v1/compute.py:83-90,252,434), which the UI never sends — the UI foundation issue threads it.

Wave Backbone (climate-lama) UI (climate-lama-ui)
W0a #1182 JOB_FAILED + error_code (migration 0089) · #1184 jobs list · #1166 exposure-datasets API · #1187 read-all + title · #1189 upload supported_years · #1190 branding defaults · #1191 catalog coverage · #1180 scope coverage · #1192 W0.0 owner precondition (ops) ui#308 client foundation I (envelope, message_en, error boundary)
W0b #1183 all FAILED writers via mark_failed · #1167 portfolio_datasets migration 0091 · #1168 attachments + /members · #1185 ImpactRequest scenario/horizon · #1186 scenario contract ui#309 client foundation II (all new contracts + X-Engine)
W0c #1169 compute + rollup on attachments · #1175 supersede ADR-047 · #1179 per-exposure results + latest per portfolio · #1181 portfolios summary · #1188 SDK —
W1 #1170 streaming uploads + h5py · #1171 HDF5 reader (Opus·max) · #1172 exposure .h5 + SDK · #1173 hazard adapter · #1174 hazard .h5 endpoint · #1176 docs ui#307 upload dialogs
W2 — ui#317 reset · ui#318 rails + measures hidden · ui#319 result map + EVENTS · ui#320 dataset picker · ui#321 horizon→dataset · ui#322 multi-cell + grid + ?batch= · ui#323 delete /matrix · ui#324 engine option + compare, delete /agreement · ui#325 bounded poller + N of M · ui#326 save/open round-trip
W3 — ui#302 members + map · ui#303 add assets (L) · ui#304 per-asset results + polish · ui#305 lookup · ui#306 dataset inspector part 1 · ui#327 dataset inspector part 2 (split of the Fable candidate)
W4 — ui#311 nav of eight + /assess · ui#310 admin console · ui#312 Home shell · ui#313 Home cards · ui#314 Runs hub · ui#315 contract spec · ui#316 dead code + i18n
W5 #1177 smoke assertions · #1178 docs sync · #1193 W5.1 prod residue (ops) —

Backbone roots: #1166, #1170, #1171, #1180, #1182, #1185, #1189, #1190. Chains:

1182 → #1167 → #1168 → #1169 → #1175 (gated on #1192's count); #1168 → #1179 → #1181;

1185 → #1186; #1171 → #1173 → #1174 → #1176; everything → #1188 → #1178. Migrations

land serially (#1182 then #1167). UI: ui#308 → ui#309 (waits on eleven backbone contract issues, written fully qualified) → every consumer; ui#311 (nav) before the route deletions ui#323/ui#324 and before Home ui#312; /matrix deletion (ui#323) also waits on ui#324 and ui#314, which consume matrix.json. Land with /phase-build phase-14 in the backbone first, then in the UI repo. Sizing: 50 of 52 build issues Opus (#1171 at max — security-sensitive deserialisation); ui#316 and #1176 Sonnet; no Fable.

Superseded 2026-09-08 by the Final work order below; kept for the record, rows not maintained. The dispatch advice here ("Land with /phase-build phase-14 in the backbone first, then in the UI repo") describes the original fourteen-session plan, not what ran. Migration attribution, corrected: 0089_job_failed_error_code came from #1182 (PR

1196), 0090_exposure_dataset_owner_and_size from #1170 (PR #1208, the streaming-upload

work), and 0091_portfolio_datasets from #1167 (PR #1212) — verified with git log --diff-filter=A -1 per migration file and by reading each down_revision.

Orchestration plan (2026-09-05 — fourteen /phase-build sessions, verified by /reassess fast)

/phase-build phase-14 over 52 issues does not fit one orchestrator session. The phase is landed as fourteen sessions, each /phase-build <explicit issue list> in one repo, sized to the band the last three phases proved (3–5 issues, 2.5–5 h; Phase 13 W0 = 5 issues ≈ 5 h, W2+W3 = 3 issues in 156 min). Both repos are private with allow_auto_merge: false → DIRECT-MERGE mode, and both declare on-demand CI → NO-PR-CI mode; the orchestrator merges on mergeable and the session-end trunk gate (ci.yml on main, debounced) is the only full-suite verification. Basis for budgets: ≈60 min per Opus build in series (S issues ≈40), ≈52 min amortised when two run concurrently, plus ≈20 min per session for the partition refuter, land-waits and the trunk gate.

Standing rules for every session (each traces to a verified failure or a skill clause):

  1. Waves are an explicit override, not the skill's default. /phase-build partitions on the union of the dep and overlap graphs and runs a dep-connected component as one serial chain; the layers below dispatch issues of one component concurrently when their Files: lists are path-disjoint and share no hotspot category (Phase 13 W1 precedent: wave A #1023 ∥ #1024). The dispatch prompt states the override per layer, with the evidence. A session's partition refuter may collapse a layer to serial; budgets absorb one collapse.
  2. docs/CLIMATE_LAMA.md is a real overlap edge (Phase 12 W0: #1047/#1052 serialised on it; the orchestrator never rebases a DIRTY PR — it stops). At most one docs-editing issue per wave, dispatched first; where every issue in a set edits it, the set is serial.
  3. Parallel: no is a serialise-only signal — never permission to parallelise. An issue so marked runs beside another only with the override sentence naming why the partner touches nothing in its spine.
  4. Backbone prompts carry: one TEST_DATABASE_URL per concurrent builder (climate_lama_test_2/_3/_4), never --fast; after the scoped tests, the repo-wide guards regardless of diff scope — uv run pytest tests/test_no_string_raises.py tests/test_enums.py tests/test_changelog.py tests/test_env_template_parity.py -q (Phase 12 W0 went red on test_no_string_raises from a core/errors.py diff; there is no per-PR CI to catch it); and, for any PR touching migrations/**, models/**, core/errors.py or sdk/python/climate_lama_client/errors.py, the note that migrations-gate.yml / sdk-regenerate.yml are real blocking checks — land-wait polls until the rollup is non-empty and green, never the empty-rollup branch.
  5. UI prompts carry: never the full npm test (≈9 spurious jsdom timeouts under load — climate-lama-ui/CLAUDE.md); run the changed test files in isolation at the same SHA and, on a red full-suite, re-run those files alone before reporting; the test:e2e:smoke acceptance box is satisfied by the orchestrator's e2e-smoke.yml dispatch after the session's last merge (builders have no Docker), recorded as run id + backbone main SHA; cross-repo Deps: resolve with --repo CortoMaltese3/climate-lama (skill amended 2026-09-05).
  6. Concurrency. Never two sessions in one repo (hotspots, one trunk-gate debounce, and in the backbone the shared local test DBs). A backbone session may run beside a UI session, but their combined live builders stay ≤3 (backbone ≤2, UI 1 while both are live; a session alone may use 3) — the vitest timeout noise is a load artefact of this machine.
  7. Ops issues never enter a session list (/build auto-ticks every checkbox): #1192 and #1193 are owner-run.

Backbone sessions (climate-lama, strictly in this order)

Session /phase-build … Shape (→ serial, ∥ concurrent) --max-runtime Notes
B1 failure visibility 1182 1183 1184 1187 #1182 alone → #1183 ∥ #1184 → #1187 300 #1182: migrations-gate + sdk-regenerate real checks; #1183: sdk-regenerate. Wave override on L2 (disjoint: worker/ingest vs api/jobs + job repository)
B2 exposure datasets + small contracts 1166 1191 1189 1190 serial: #1166 → #1191 → #1189 → #1190 300 all four edit docs/CLIMATE_LAMA.md (rule 2)
B3 compute contract + W1 roots 1170 1171 1185 1186 #1170 ∥ #1171 → #1185 → #1186 300 #1171 = builder-max; #1170 seam paths (Dockerfiles) → resolution box; #1185 is Parallel: no and shares api/v1/ with #1170, so it follows
B4 portfolio membership 1167 1168 1169 serial: #1167 alone → #1168 → #1169 240 migration 0090 (migrations-gate real check)
B5 results, coverage, smoke 1177 1180 1179 1181 #1177 ∥ #1180 → #1179 → #1181 300 #1177 edits scripts/smoke_e2e.py → dispatch the UI e2e-smoke.yml sibling gate; after B5 the UI foundation (ui#309) is unblocked
B6 upload matrix 1173 1172 1174 1176 serial: #1173 → #1172 → #1174 → #1176 300 #1172/#1174 share sdk/python/climate_lama_client/; #1174 Parallel: no + sdk-regenerate real check; #1176 Sonnet
B7 W0 closeout + SDK + docs 1175 1188 1178 serial: #1175 → #1188 → #1178 240 Owner gate: prod SELECT count(*) FROM portfolio_assets recorded on #1192, pasted into #1175, and reads 0 (if > 0: owner deletes the ADR-077 residue and re-records first — #1175's first acceptance box hands the build back otherwise). #1175 + #1188: sdk-regenerate real check. Run after U6; it has no downstream but the release

UI sessions (climate-lama-ui, strictly in this order)

Session /phase-build … Shape --max-runtime Prerequisites / notes
U1 foundation I, admin, nav, reset 308 317 310 318 311 #308 ∥ #317 → #310 ∥ #318 → #311 300 none — starts alongside B1. Override sentences for #317/#318 (Parallel: no; partners touch nothing in the Dashboard/ConfigPanel spine)
U2 foundation II + independents 309 315 319 306 305 #309 alone → #315 ∥ #319 → #306 ∥ #305 300 B1–B5 merged (ui#309 names eleven backbone issues). e2e infra (#315: helpers.ts, playwright.config.ts) lands before any new spec (#306); #305 follows #319 (LayerPanel.tsx)
U3 Assess I 320 321 324 serial 240 Dashboard/ConfigPanel spine
U4 Assess II 325 326 322 serial 240 same spine; #325 needs #319 (U2)
U5 Runs + deletions 314 323 316 serial 240 App.tsx + i18n/index.ts; #316 Sonnet. Then gh workflow run publish.yml --ref main → a walkable sha-<short> image (UI_TAG= on the fullstack compose), no version bump
U6 Portfolios + Home 302 312 303 313 304 #302 ∥ #312 → #303 ∥ #313 → #304 300 B2/B4/B5 + U1 + U3. Prompt notes: #303's agreement/ and matrix/ importers now live under components/assess/ (moved by ui#324/ui#322 — its Files: list is pre-move); #313 imports Spinner from components/Spinner.tsx, never from UploadModal.tsx, which #303 deletes in the same layer
U7 uploads + inspector part 2 307 327 serial (datasets.json) 240 B6 merged (#1172 #1174 #1189)

After every UI session: gh workflow run e2e-smoke.yml --ref main on climate-lama-ui, land-wait it under the trunk-gate debounce, record run id + backbone main SHA (guardrail 2 and exit criteria 3–4 need it green at the merged SHA; a builder cannot run it).

Order, critical path, calendar

Critical path: B1 → B2 → B3 → B4 → B5 → U2 → U3 → U4 → U5 → U6 → U7 → R2 → deploy → walk (eleven build sessions). Off the path: U1 ∥ B1; B6 anywhere between B5 and U7; B7 after U6 and after the #1192 gate. At ≈3.5 h per session and one session per repo at a time, the phase is ≈6 working days of orchestration.

Release checkpoints (phase amendment: one mid-phase, one at close, pg_dump before each)

  • R1 backbone after B7 (W0 complete incl. #1175/#1188, W1 complete via B6 — not after B6, which would ship W0 without the retired read path or the SDK). pg_dump first (migrations 0089 + 0090 ride it); bump pyproject.toml + src/climate_lama/__init__.py (+ SDK version and uv lock) in one PR; gh workflow run ci.yml --ref main and sdk-smoke.yml --ref main; scripts/verify_ci_green.sh --sha $(git rev-parse HEAD); then /tag. The UI has no mid-phase release — the U5 publish.yml image is the walkable artefact.
  • R2 both repos at close: after U7, after #1193 (owner deletes the prod demo residue), backbone pg_dump again; UI bumps package.json + package-lock.json and dispatches ci.yml on main as its gate. Deploy both to prod. The owner's five-question walk (exit criterion 1) is the last step of the phase, on the deployed R2, not "after U7".

Orchestrator model / effort

Opus · xhigh for all fourteen sessions. The orchestrator writes no code and resolves no conflict (law 2 stops and reports), so no session is Fable-shaped — Fable weighs ≈2× Opus against a 50 % weekly cap and would spend it on 3–5 h of cached-context turns. Effort stays xhigh rather than high because the orchestrator's judgment calls — stall vs hang, the pre-dispatch premise check, a red trunk gate's revert order — are where one wrong call costs a revert wave; the effort dial is ≈8.5 % of spend. Builder tiers are fixed per issue by /analyze (50 Opus, #1171 at max,

1176 and ui#316 Sonnet) and are not the orchestrator's to change. No Sonnet orchestrator: in

NO-PR-CI + DIRECT-MERGE mode the orchestrator is the merge gate.

Reassess record (2026-09-05, fast, 3 agents)

Confirmed: explicit issue lists + dep-closed ordering; the cross-repo owner/repo#N gap (skill amended: step 1 keeps the prefix, step 2 resolves with --repo); per-agent test DB missing from every skill (skill precondition amended); Opus·xhigh orchestrators; recording here. Corrected: waves are an override (rule 1); docs file is a real edge (rule 2 — B2 and B6 went serial); Parallel: no pairs (rule 3 — B3/U2 re-layered); budgets raised; concurrency needs the combined ≤3 cap; #1192 gate must read 0, not merely "posted"; R1 moved after B7 and the UI mid-phase release replaced by a publish.yml image; the walk sequenced after deploy. Added: repo-wide guard tests per backbone session; B7's real checks; the UI e2e-smoke.yml dispatch per session; the UI vitest isolation line; ui#303's pre-move Files: list and the Spinner collision with ui#313; the release checklist. Dropped: a backbone-side contract-break window for the UI gate — the UI never calls GET /v1/risk/assets/{id} (grep 2026-09-05).

Orchestration — status 2026-09-07 and the second path (nine sessions)

Superseded 2026-09-08 by the Final work order below; kept for the record, rows not maintained.

Done. B1–B6 and U1–U2 landed (backbone trunk a047d74, UI trunk 4211eb0); the mid-phase release was cut after B6 rather than B7 — prod = backbone v0.23.0 + UI v0.13.0 (coordinated deploy run 34051305981, pg_dump 20260906_181653, Alembic head 0088 → 0091: 0089 job_failed, 0090 exposure-dataset owner + size taken by #1170, 0091 portfolio_datasets). Backbone gate green at every wave (5457 → 5961 tests). Closed as built: #1166 #1169 #1171–#1174

1182–#1187 #1189–#1191; ui#305 #306 #308–#311 #315 #317–#319.

Three facts the second path is built on:

  1. The UI smoke gate is red on trunk and has been since 2026-09-06 09:43 (e2e-smoke.yml runs at bf9ac8f, 8107615, 8bec920, 4211eb0 all fail on one spec, datasets.spec.ts:27, the inspector spec ui#306 shipped unverified — ui#339). Guardrail 2 and exit criteria 3–4 need this gate green, so ui#339 is the first UI issue, not the Assess chain.
  2. Seven merged backbone issues are still OPEN — #1167 #1168 #1170 #1177 #1179 #1180 #1181 each landed with Refs because one acceptance box needs Docker (worktree builders have none). /phase-build's dependency gate read the issue state and would have false-halted B5; the skill now resolves a dep by merged-PR + trunk grep (amended 2026-09-07). #1221 (owner-run scripts/smoke_e2e.py against a real stack) closes all seven; ui#302/#304 name three of them.
  3. The waves filed 17 follow-ups. Ten backbone: #1200 #1205 #1206 #1207 (B1/B2 composed-defaults), #1214 #1215 (B4 dual-write and unpaged /aggregate), #1225 #1228 (B6: the grid-cells ceiling kills the 100-event cap above ~9 return periods; a failed .h5 ingest burns up to 4 GB of quota permanently), #1222 (decision), #1221 (ops). Seven UI: ui#339 (red spec), ui#341 (four client methods on routes the backbone does not serve), ui#345 (three admin DTOs with phantom fields — supersedes ui#328), ui#346 (three composed defects from the impact_per_event retype), ui#343 (switch on the choropleth tier), ui#332 (already fixed by PR #336 — close as duplicate of ui#335), ui#328 (close as superseded by ui#345).

Lessons the waves earned, now standing rules for every remaining dispatch (each from a session memory):

  1. Premise greps read git show origin/main:<path>, never the working tree (B4, B6 — the shared checkout sat five commits behind).
  2. Budget ≈100 min per Opus·xhigh issue when serial (B6: 110/85/73 min; B4's #1169 hit a 240-min cap). Watchdog grace ≥110 min; confirm liveness (ListAgents + worktree git log) before treating an expiry as a stall; leave a near-done builder alive at the cap.
  3. One pytest process per builder, not just one DB (B3: two of one agent's own runs corrupted each other).
  4. Merge on the builder's completion JSON, never on PR appearance or branch quiescence (U2: ui#315's decisive commit arrived six minutes after a five-minute-quiet branch).
  5. Run the composed-defaults pass per merge range, and again for any late PR that consolidates earlier ones (B6: two of three composed defects were in the last PR).
  6. When a builder reports a passing count / no-N+1 / naming test, ask what the fixture would have to look like for the test to be able to fail (B5: three green-by-construction tests in a row, always the fixture).
  7. UI dispatches name tests/scroll_contract.test.ts explicitly and treat src/components/lookup/*, src/components/LayerPanel.tsx, src/map/* as a shared surface even when one issue lists them (U1/U2). A new smoke spec is unverified until the post-merge e2e-smoke.yml run — budget a follow-up.
  8. Reuse extraction is an overlap the plan cannot see, in both directions. (a) Before a dependent issue is dispatched, grep its predecessor's reuse surface on trunk and name the symbol (B5 portfolio_results_query, B6 inspect_climada_hazard); when two chained issues share an undecided seam, decide it against trunk and put the identical sentence in both dispatches. (b) For issues dispatched in parallel, every file either issue reads is jointly owned: each dispatch says "if you extract a shared helper, extract it into a new module and do not modify a file outside your Files: list — report the extraction in your completion JSON", and the composed pass runs over the merge range (U2: ui#306 moved formatLegendValue / colormapGradient out of LookupMap.tsx, a file ui#305 owned; PR #338 came back CONFLICTING).
  9. Session end reports the month's Actions minutes with the working endpoint (the old settings/billing/actions path returns 410; no leading slash under Git Bash): gh api "users/CortoMaltese3/settings/billing/usage?year=<Y>&month=<M>" --jq '[.usageItems[]|select(.product=="actions" and .unitType=="Minutes")]|{minutes:(map(.quantity)|add),net:(map(.netAmount)|add)}' — net > 0 is the escalation trigger, not the minute count. Baseline 2026-09-07: 547 min, net 0.
  10. A real-stack step proves what it ran against, not what up --wait reported. Dev builds serve git_sha: "unknown" on GET /v1/info, so a SHA check is not available; the api service bind-mounts ./src and ./scripts (serves the checkout) but worker, migrations and bootstrap COPY at build time and compose never rebuilds an existing image. The #1221 run's first attempt used a cached migrations image from 2026-04-24 holding 34 migration files: alembic upgrade head ran to its head 0034, exited 0, and every service reported healthy. Rule: git pull, docker compose build worker migrations bootstrap, then assert SELECT version_num FROM alembic_version equals the checkout's head (0091 today) before the walk, and record the checkout SHA in the evidence.

Backbone sessions (in this order; B10 gated by the owner)

Session /phase-build … Shape --max-runtime Notes
B8 upload-matrix hardening 1207 1232 1225 1228 1176 #1207 ∥ #1232 ∥ #1225 → #1228 → #1176 300 #1207 test-only (Sonnet·xhigh); #1232 deflake two load-sensitive tests (Opus·high, test-only); #1225 Opus·high; #1228 Opus·xhigh, Parallel: no (hazards.py, tasks.py, aggregate_and_commit.py); #1176 docs (Sonnet) last so it documents the post-#1225 cap semantics — its acceptance 2(b) was corrected on the issue 2026-09-07 (valid_at rides params["event"], the declaration carries the label only). #1225/#1228 share config.py
B9 read-side fixes + the ingest bound 1200 1205 1214 1239 1215 #1200 ∥ #1205 ∥ #1214 ∥ #1239 → #1215 360 #1200/#1205 Opus·high; #1214/#1239/#1215 Opus·xhigh. #1214 is Parallel: no — override: its spine (portfolio_service.py, portfolio_repository.py) is untouched by #1200/#1205/#1239. #1239 (the /v1/hazards/ingest route has no quota and, after #1225, no product bound — a regression the phase opened, admission gate i) touches api/v1/hazards.py + core/ingest/climada_hazard.py, disjoint from the rest. #1214 → #1215 share portfolio_service.py. #1206 moved off the milestone 2026-09-07 (perf-only, named by no exit criterion → found-in:phase-14, closeout triage); #1240 likewise (pre-existing sidecar RST). Decision pinned in #1215's dispatch: by_exposure becomes paged (limit/offset/total, same shape as /members), totals in SQL — it is a UI-visible contract change, mirrored by ui#304 (below). Must land before U6
B10 W0 closeout + SDK + docs 1175 1188 1178 serial 300 Owner gate unchanged: #1192 count pasted into #1175 and reading 0. #1175/#1188 → sdk-regenerate.yml real check. #1178 also restates docs/DEPLOYMENT.md's dump sizing (602 MB on 2026-09-06, not ~257 MB) — added to its Files: 2026-09-07

UI sessions (in this order)

Session /phase-build … Shape --max-runtime Prerequisites / notes
U3′ trunk green + contracts 339 346 341 345 343 #339 ∥ #346 → #341 → #345 → #343 360 Runs first, now, beside B8. Before dispatch: close ui#332 (fixed by PR #336) and ui#328 (superseded by ui#345). #341/#345/#343 chain on client.ts + types/index.ts; #346/#343 on ResultMap.tsx; #339/#341 on DatasetInspector.tsx. #341 seam path tests/e2e/smoke/contract-map.ts → Opus·xhigh; #345/#343 Opus·high; #339/#346 Opus·xhigh. Session ends with e2e-smoke.yml green — that is the exit, not the last merge
U3 Assess I 320 321 324 serial 360 after U3′ (#343 touched Dashboard.tsx)
U4 Assess II 325 326 322 serial 360
U5 Runs + deletions 314 323 316 serial 300 then gh workflow run publish.yml --ref main for a walkable image
U6 Portfolios + Home 302 312 303 313 304 #302 ∥ #312 → #303 ∥ #313 → #304 420 B9 merged (#1215's paged by_exposure; #1214) and #1221 run (else the gate meets #1168/#1179/#1180 OPEN — the amended skill verifies them by PR + trunk). #1222 decided before #313 (recommended: option 1, label the two planes separately on the Home card; no API change in Phase 14). ui#304's dispatch gets one explicit exception to consumer-only: it owns the aggregate client method + type for the paged shape. #303's Files: is pre-move (agreement/, matrix/ → assess/); #313 imports Spinner from components/Spinner.tsx
U7 uploads + inspector part 2 + UI doc 307 327 347 #307 → #327 → #347 300 B6 merged ✓; ui#347 syncs docs/CLIMATE_LAMA_UI.md (routes, nav of eight, client contracts) after the deletions

Master sequence — every step from here to CLOSED (2026-09-07, verified by /reassess fast)

One row per step; "∥" = the two may run at the same time (one session per repo, combined live builders ≤3). Orchestrators Opus · xhigh; builders per issue.

# Step Who Gate to pass before the next step
1 ~~Close ui#332 (dup of ui#335) and ui#328 (⊂ ui#345); decide #1222 = option 1 (acceptance moved onto ui#313); add ## Execution to #1205 #1206 #1207 #1215; complete sizes on #1200 #1225 ui#341 ui#343 ui#345; put the twelve follow-ups on the milestone; file #1232 (deflake) and ui#347 (UI doc); correct #1176 2(b), extend #1178 and #1221~~ orchestrator done 2026-09-07
2 U3′ /phase-build 339 346 341 345 343 --no-pr-ci --max-runtime 360 ∥ ~~B8 /phase-build 1207 1232 1225 1228 1176~~ (B8 landed 2026-09-07: PRs #1234 #1236 #1235 #1238 #1241; #1228 split the densified-bytes ceiling into #1239, taken by B9) orchestrators UI e2e-smoke.yml green on UI trunk (green at ee91e23, 2026-09-07 18:29); UI ci.yml is RED at ee91e23 — ui#361, owned by the live UI orchestrator, must be green before U4 merges anything
3 ~~B9 /phase-build 1200 1205 1214 1239 1215~~ (landed 2026-09-07: PRs #1243 #1244 #1245 #1246 #1247 #1248 #1249; #1215's paged by_exposure is on trunk) ∥ U3 /phase-build 320 321 324 --no-pr-ci --max-runtime 360 (ui#324 landed; ui#320/#321 open at the time of writing) orchestrators UI trunk gates green
4 ~~#1221 real-stack walk; #1192 prod count~~ — done 2026-09-07 20:33 UTC. Count = 0 recorded on #1192/#1175 (the owner's > was an output marker); walk exit 0 both legs at checkout d94b5d4 / schema 0091, h5py 3.16.0 in api and worker, evidence on #1221; closed #1167 #1170 #1177 #1175 #1188 #1178 #1192 #1221 owner + peer session backbone milestone = #1193 only
5 U4 /phase-build 325 326 322 --no-pr-ci --max-runtime 360 ∥ ~~B10 /phase-build 1175 1188 1178~~ (landed 2026-09-07: PRs #1251 ADR-080, #1253, #1254; sdk-regenerate.yml green) orchestrator UI gates green
6 U5 /phase-build 314 323 316 --no-pr-ci --max-runtime 300, then gh workflow run publish.yml --ref main (walkable sha- image). Waits on backbone B12 (#1270: compute-group DELETE + resolved inputs — ui#314's box 2) orchestrator UI gate green; /matrix /agreement gone from App.tsx
7 U6 /phase-build 302 312 303 313 304 --no-pr-ci --max-runtime 420 — ui#304 owns the paged-aggregate client mirror; ui#303's Files: is pre-move; ui#313 imports Spinner from components/Spinner.tsx and carries #1222's labelling box orchestrator UI gate green; steps 3 and 4 done first
8 U7 /phase-build 307 327 347 --no-pr-ci --max-runtime 300 orchestrator UI e2e-smoke.yml green at the merged SHA, incl. the contract spec
9 #1193 — delete the prod demo residue (Attica / Daniel demo portfolios, "walk" dataset) owner GET /v1/portfolios shows no demo book
10 Docs closeout PR: rewrite showcase-script.md Moment 3 onto /assess + /runs (W5.3 — #1178 disowns it; step 6 already rewritten onto Assess by PR #1252, 2026-09-07 — steps 1–5 and the surfaces line still name /matrix and /scenarios → /compare, owed after ui#323/ui#314); update the phase doc's Issues table (0090 → #1170, 0091 → #1167) orchestrator merged
11 R2 backbone: prod pg_dump; one PR bumping pyproject.toml + src/climate_lama/__init__.py + SDK version with uv lock; gh workflow run ci.yml --ref main and sdk-smoke.yml --ref main; scripts/verify_ci_green.sh --sha; /tag. R2 UI: bump package.json + package-lock.json, ci.yml on main, tag. Deploy both (deploy-prod.yml runs alembic upgrade head), verify-deploy.sh owner + orchestrator /v1/info serves the new version; UI bundle hash moved
12 Pre-walk data check on prod: ≥1 non-demo portfolio answers /members with lat/lon/value and ≥2 saved results exist to reopen and compare (the northlane live-event books are candidates); if not, ingest through the real pipeline owner walkable data confirmed
13 The walk: the five questions on prod (Home → Assess single + multi-cell → Lookup → portfolio with assets on the map + ranked table → Datasets inspector → Runs reopen + compare) and showcase Moment 3 re-walk; findings tagged [CRITICAL] / [ENHANCE-NOW] / [ENHANCE-NEXT] / [BACKLOG] / [DROP] owner findings recorded
14 Remediation loop: any [CRITICAL] → fix, R2.1 patch release, redeploy, re-walk the failing question; repeat until zero [CRITICAL] (or record a Blocked/Partial verdict with the reason, per the framework) orchestrator + owner zero [CRITICAL]
15 Close: docs/plan/mid-review-phase-14.md with the verdict; scripts/rework_rate.py --since 2026-09-05 --window-hours 168 --follow-ups run twice (backbone, and --repo CortoMaltese3/climate-lama-ui), both fixes/build and follow-ups/build in the mid-review (> 25 % on either trips D15 — the PR-only ratio read 0.0 on 2026-09-07 while 15 follow-up issues had been filed); the found-in:phase-14 issues triaged with the framework tags in the same doc (none is a closure blocker); both GitHub milestones at 0 open and closed; plan.md banner + status log + phase table flipped; exit criteria 1–6 ticked in this file orchestrator Phase 14 CLOSED
16 (On the owner's prompt, separately) app-wide sweep for the same defect classes → Phase 15 inputs orchestrator —

Critical path: 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 11 → 12 → 13 → 14 → 15. B8 and B9 ride beside U3′ and U3; B10 slots in wherever #1192 is answered before step 11; step 10 can run any time after step 6.

Reassess record (2026-09-07, fast, 2 agents — "worth attention" items + master sequence)

Own items confirmed: #1232 (deflake the sidecar-port and COG-stage tests; the third test B3 named, tests/worker/ingest/test_watchdog.py, was rule 10's one-pytest-per-builder case, not flakiness). Corrections that would have misled a builder: #1176's acceptance 2(b) stated the .h5 seam backwards; #1178 excluded DEPLOYMENT.md from its Files:; #1221 listed four issues already closed. Rule 15 rewritten (parallel reuse extraction); rule 16 added (billing endpoint moved). Not items: LIVE_MODE_ENABLED=false in .env.example.prod vs true on prod (documented inline; no test asserts the value; Phase 15/ops note); the backup free-space floor (7 × 602 MB against ~18 GB free); worker-image h5py (load-bearing since #1173); Actions minutes (547 of 3000 on 2026-09-07). Missed steps added to the sequence: showcase Moment 3 rewrite (10), UI doc ui#347 (8), milestone hygiene (1, 15), circuit-breaker on both repos (15), remediation loop (14), pre-walk data check (12). No unfiled user-visible defect in the eight session notes.

Backbone residual work order (2026-09-08 — while the UI sessions U4–U7 run)

Superseded 2026-09-08 by the Final work order below; kept for the record, rows not maintained. Rows B-4..B-8 duplicate F-5..F-8 and are stale in detail — B-6 still says "SDK version", which R2 did not bump.

State. Backbone build sessions B1–B10 are all landed (trunk d94b5d4 + docs; gate green, 6064 tests). The real-stack walk (#1221, WSL, exit 0 both legs at schema 0091) closed the nine Refs-open issues and #1192 (portfolio_assets = 0 recorded, ADR-080 quotes it). The milestone holds #1193 (owner) and, admitted 2026-09-08 under gate (i), #1237. Four off-milestone follow-ups carry found-in:phase-14: #1237, #1240, #1250, #1255. Prod is still v0.23.0 — B7–B10's fixes (quota burn #1228, event cap #1225, paged /aggregate #1215, ADR-080, SDK 0.2.x) are merged, not deployed. The backbone must not be released ahead of the UI: #1215's paged by_exposure breaks the portfolio page the deployed UI v0.13.0 renders; ui#304 (U6) is its mirror, so R2 is a coordinated release after U7, as v0.23.0 was. Rework, backbone, 2026-09-05..07 (rework_rate.py --follow-ups): 5 fix PRs over 38 builds (13 %), 12 follow-up issues (Opus·xhigh: 0.21 fixes/build, 0.43 follow-ups/build) — below the 25 % D15 trip on fixes; the follow-up ratio is the number the mid-review must explain. Docs: commit 658b0a3 ("modify phase-14 docs", pushed straight to main 15 minutes after PR #1256) was a stale working copy — it reverted #1256 (rows 2–5 struck, rule 17, the B9 list with #1239), PR #1252 (showcase Moment 3 step 6 on Assess) and the [SUPERSEDED by #1225/#1228] markers on D10/W1.4a/W1.4b. The 2026-09-08 docs PR restored all three from ea85326.

Triage of the four found-in:phase-14 follow-ups (admission gates i–iii):

Issue Verdict Why
#1237 /uploads never reclaimed on POST /v1/hazards/upload admit, build in B11 (gate i) the disk staging it leaks arrived with #1170 this phase; unbounded growth on the prod volume. Pinned: option 2, api-side reclaim sweep on the job's terminal state; Opus·xhigh; seam paths docker/core.Dockerfile, docker/worker-entrypoint.sh; its real-stack box closes on the owner's WSL walk
#1255 F821 in scripts/score_band_reachability.py + MkDocs anchors build in B11, stays off-milestone the script is the #1048 prod-evidence tool and currently crashes on that path; pinned: MkDocs slugs win, validation.links.anchors: warn; Opus·high
#1250 test_hazards_features_bbox_1m_under_100ms ORM half flakes under load build in B11, stays off-milestone builder-time only, two builders hit it with controls; Sonnet·xhigh, one test file
#1240 CLIMADA sidecar RSTs an error response → E_ENGINE_UNAVAILABLE misreport defer to Phase 15 pre-existing, no exit criterion names it, reaches a user only on a CLIMADA-engine error path; Execution block added so the Phase 15 split can lift it
# Step Who Gate to pass
B-1 ~~Docs restore + Execution blocks on #1237 #1255 #1240 + #1237 admitted~~ orchestrator done 2026-09-08
B-2 ~~B11 /phase-build 1237 1255 1250~~ — landed 2026-09-08: #1237 fix PR #1263 (55b45fa, reclaim on terminal ingest via core/hazard_upload_reclaim.py), #1255 → PR #1261, #1250 → PR #1262; B-3 tooling fixes #1264 #1265 #1268 #1269 rode along; trunk 5718fb3 orchestrator done
B-2b B12 /phase-build 1271 1270 1272 --no-pr-ci --max-runtime 300 — shape #1271 ∥ #1270 ∥ #1272 (disjoint: worker/ingest chain wiring · api/v1/compute.py + scenarios.py + schemas · scripts/smoke_walk_wsl.sh + RUNBOOK). #1271 (Opus·xhigh) routes every validate_source InvalidSourceError to mark_failed in seconds, keeping the watchdog as backstop — lands Refs (real-stack box → B-3′). #1270 (Opus·xhigh) = batch-list inputs_resolved, scenario-list inputs_resolved (drops the UI N+1), and a compute-group DELETE route — UI session U5 (ui#314) depends on it; land B12 before U5; UI e2e-smoke.yml sibling run after merge (contract spec). #1272 (Sonnet·xhigh) = --clean-slate for the walk script, guarded against prod. #1267 (docs-gate early warning, scripts/ lint scope, #226 perf guard) deferred to Phase 15 orchestrator backbone ci.yml green; UI sibling green
B-3 Executed 2026-09-08 (evidence: #1237 comment 5578236717) — walk gate MET, #1237 NOT closed. WALK_EXIT=0 in 35 s, both legs, trunk 8ea3ac9, alembic_version 0091. Upload probe: succeeded PASS (/uploads 1 → 1, leaf gone, root present), cancelled PASS (2 → 1, via POST /ingest-jobs/{id}/cancel), failed NOT EXERCISED — a validate_source failure never marks the ingest job failed (status stays running, error_code NULL, until the 6 h wedged-ingest watchdog), filed as #1271. Run needed manual pre-cleanup (the walk is not idempotent; exposure datasets have no DELETE route → SQL, which orphaned six asset rows) → #1272; build skipped for host memory, justified by 0d300f9..8ea3ac9 touching only the script and by importing the reclaim module from the running worker. Restore clean. Re-run as B-3′ after B12 with scripts/smoke_walk_wsl.sh --clean-slate --require-merged '1271:^fix\(' --require-symbol <path>:<symbol #1271 adds> and close #1237 on the failed branch passing Sequencing: run B-3′ only after B12's builders have exited — the WSL host was at 2.4 GB free of 31.7 during B-3 (WSL capped at 8 GB by .wslconfig), docker compose build was killed twice, and B-3′ must rebuild worker/migrations because #1271 changes src/. --clean-slate is safe without a backup: bootstrap recreates the org and admin, each leg loads its own GADM boundaries, impact functions seed at stage 3; the /uploads baseline was expected to become 0 (a pre-existing dataset-ingest/ dir from 2026-08-25 was assumed to make it 1) — corrected by B-3′: the steady state is 1, not 0. With every named volume dropped it is still 1, so that entry is the walk's own server-side ingest staging directory, not stale residue. Migrations run 0001→0091 from scratch, a stronger check than B-3's incremental path owner (WSL box) by default; the lama-phase-build-P14-B3-residuals session takes it opportunistically if still open failed branch: leaf gone within the sweep interval; #1237 and #1271 closed
B-4 #1193 delete the prod demo residue (Attica / Daniel demo portfolios, "walk" dataset) — any time before R2 owner GET /v1/portfolios shows no demo book; milestone at 0 open
B-5 Pre-release gates on the release SHA: gh workflow run ci.yml --ref main, gh workflow run sdk-smoke.yml --ref main (first smoke since #1188 changed the SDK), scripts/verify_ci_green.sh --sha <HEAD> orchestrator both green on that exact SHA
B-6 R2 backbone, coordinated with the UI R2 after U7: prod pg_dump (no new migration since 0091, dump anyway); one PR bumping pyproject.toml + src/climate_lama/__init__.py + CHANGELOG.md (+ SDK version if #1188 did not) with uv lock; /tag v0.24.0; deploy-prod.yml for both images; verify-deploy.sh; re-check /v1/info owner + orchestrator /v1/info serves v0.24.0 at the tagged SHA
B-7 Backbone exit evidence ticked in this file: criterion 4 (W5.2 assertions in smoke_e2e.py ✓ #1177; sdk-smoke.yml green at the R2 SHA), criterion 5 (portfolio_assets = 0 ✓ #1192; demo residue = 0 after B-4), docs synced ✓ (#1176 #1178, ADR-080) orchestrator boxes ticked with run ids
B-8 Backbone half of the close: rework_rate.py --since 2026-09-05 --window-hours 168 --follow-ups quoted in the mid-review (fix ratio and follow-up ratio both), milestone closed at 0 open; the joint steps 12–15 of the master sequence (pre-walk data check, walk, remediation, mid-review, banner) run once the UI is done orchestrator Phase 14 CLOSED

Facts corrected while writing the script (read from source, propagate anywhere else they are written): login returns data.access_token in the JSON body and it is sent as a bearer header (api/v1/auth.py:304, as scripts/smoke_e2e.py does) — not a cookie; the ingest-job route is GET /v1/hazards/ingest-jobs/{id} (api/v1/hazards.py:588) with terminal states succeeded / failed / cancelled from IngestJobStatus, while the generic /v1/jobs/{id} lane says completed — two enums, easy to cross-wire; a merged-PR body search is a weak gate (five merged PRs already cite #1237, none the fix), so the authoritative precondition is the symbol grep on origin/main.

Final work order (2026-09-08 evening — supersedes the two tables above; everything left to CLOSE)

State. Backbone: B1–B12 all landed, trunk 4418fa5, gate green; milestone holds #1193 (owner), #1237 / #1271 / #1272 (fix merged, one real-stack box each → B-3′) and #1270 (fix merged, UI half → U8). UI: U1–U6 landed, trunk 34b0c84, ci.yml and e2e-smoke.yml green (run 34200302371: 35 passed, 4 skipped); U7 is in flight now (session lama-ui-phase-build-14-U7: ui#307, ui#327, ui#347). Closed on that run's evidence: ui#302, ui#304, ui#312, ui#313. Still open with a real gap: ui#303 (attachment kind on reload), ui#314 (batch/comparison delete — the backbone route landed in B12), ui#325 (failure.spec.ts skips on every run: nothing sets CL_E2E_STACK_CONTROL, so exit criterion 3 is unexecuted). Prod: still v0.23.0 / UI v0.13.0. Phase 15 is defined and split (#1279–#1300, ui#378–#384) and opens at step F-12; its B3 needs #1271 merged (it is).

Circuit breaker (D15). rework_rate.py --since 2026-09-05 --follow-ups: backbone 5 fix PRs / 38 builds = 13 %; UI 9 fix PRs / 25 builds = 36 % — over the 25 % trip. Five of the nine UI fixes were trunk-red repairs of two classes now encoded as rules (isolated vitest hides cross-file regressions; a new smoke spec is unverified until the post-merge gate), three were composed-defaults fixes, one a bounded-wait fix. Decision: U8 (three S/M fixes) proceeds; the mid-review must analyse the UI ratio rather than report it.

Deferred to Phase 15, recorded here so nothing is silently dropped: #1240 (sidecar RST),

1267 (docs-gate early warning, scripts/ lint, #226 perf guard), #1303 (deterministic-order

test hang, stage_source guard), ui#358 (URL defaults), ui#350 (SKIPPED contract routes vs OpenAPI), ui#377 items 2 / 3 / 5 (JobListItem.portfolio_id needs a backbone change; per-point weight; formatNumber). The exposure-dataset DELETE route gap (B-3 hit it) is a Phase 15 candidate.

# Step Who Gate to pass
F-1 ~~Hygiene: ui#302 #304 #312 #313 closed on run 34200302371; U8 issues ui#385 #386 #387 filed with Execution blocks; type:ci label created~~ orchestrator done 2026-09-08
F-2 ~~U7 /phase-build 307 327 347~~ — landed 2026-09-08: ui#347 → PR #388 (8391cf2), ui#307 → PR #389 (1ce1a58) + fix #391 (0e9b289), ui#327 → PR #390 (d7e3de1); UI ci.yml green at 0e9b289 (run 34220513051), e2e-smoke.yml green at d7e3de1 (run 34216598913). ui#327 and ui#347 closed; ui#307 stays open on one box — its .h5 smoke leg skipped behind a stale guard ("#1172 not deployed in the smoke stack"; the stack builds the backbone from main, where #1172 has been since 2026-09-06) → ui#392, added to U8 orchestrator done
F-3 ~~B-3′ on the WSL box~~ — executed 2026-09-08 16:03, gate MET. Two consecutive --clean-slate runs at checkout 975c191, back to back with no manual step, both EXIT=0 / WALK_EXIT=0 (43 s, 44 s), schema migrated 0001→0091 from scratch each time. The failed branch passes: terminal=failed after 5s, error_code=E_HAZARD_INVALID_SOURCE, staging leaf reclaimed (/uploads 1 → 1, root present) in both runs — the branch B-3 could not reach. Symbol resolved from #1301's merged diff (_mark_job_failed), never from an issue body. #1237 #1271 #1272 closed (evidence: 1237#issuecomment-5585598540, 1271#issuecomment-5585598871, 1272#issuecomment-5585599325). Two tooling defects found by the run and fixed before the evidence was produced: #1305/#1306 — --require-merged spliced the title regex into a jq string literal, so ^fix\( (its own documented example, and this row's command) opened a jq interpolation; the parse error went to /dev/null and \|\| echo 0 reported it as "no merged PR references #1271", refusing the first attempt for a reason that was not true. #1307/#1308 — probe_branch() printed its expect_terminal as a note instead of asserting it (say returns 0), so a failure branch returning succeeded would have recorded a PASS; the probe now asserts the terminal state, asserts a non-NULL error_code, records seconds-to-terminal, and the permission grep proves it read the logs. /uploads steady state is 1, not 0 — with every volume dropped it is still 1, so dataset-ingest/ is the walk's own ingest staging dir, not the 08-25 residue B-3 took it for owner met
F-4 U8 /phase-build 385 386 387 392 --no-pr-ci --max-runtime 300 — ui#385 → ui#386 serial (client.ts, types/index.ts); ui#387 and ui#392 beside them (workflow / spec + fixture); then gh workflow run e2e-smoke.yml --ref main -f stack_control=true orchestrator ordinary and stack-control smoke runs green; ui#314 #303 #325 #307 closed, #1270 closed, ui#377 items 1/4 ticked; exit criterion 3 has run ids; both milestones at 0 open
F-5 ~~#1193 delete the prod demo residue~~ — done 2026-09-08: deletions run by the owner — two of the three items were already absent (there was no "Attica demo portfolio" and no "Daniel demo portfolio" anywhere in portfolios; the row was written from the issue body's list, not the after-state), so the "walk" exposure dataset was the only real residue; evidence comment 5586155467 (session lama-forge-1193, API proof taken 13:46:21Z in the api container: GET /v1/portfolios total 3 — Evia 2021 replay, Samos 2020 replay, Cyprus rollup proof — no demo book; GET /v1/exposures/catalog 9, no "walk"; GET /v1/exposure-datasets?search=walk 0 with and without include_superseded; superuser DB read: portfolio_assets = 0 unchanged, exposures WHERE dataset_id IS NULL = 0 — no orphan produced; the deletion order itself is not reconstructible and is not claimed). Backbone milestone: #1270 only (B-3′ closed #1237 #1271 #1272 at 975c191) owner done
F-6 ~~Docs closeout PR: exit-criteria lines carry run ids; Issues table notes 0090 → #1170, 0091 → #1167; the two superseded tables above marked closed~~ — done 2026-09-08: this PR. Criteria 2/3/4/5 carry SHAs and run ids; 1 pending F-10, 6 pending F-12; milestone line records 0 open in both repos; the two superseded tables and the stale Issues closing paragraph annotated (not struck — they are the audit trail F-12 cites); /uploads steady state corrected to 1; F-5's row corrected to record that two of its three items were already absent; the F-3 row left as PR #1309 wrote it orchestrator merged
F-7 Pre-release gates, on the POST-BUMP SHA — run after F-8's version-bump PR merges, never before: git fetch origin main, then backbone gh workflow run ci.yml --ref main + gh workflow run sdk-smoke.yml --ref main, then scripts/verify_ci_green.sh --sha $(git rev-parse origin/main); UI ci.yml + e2e-smoke.yml at the UI's own bumped SHA. Why the order matters: release.yml's verify-ci checks the tagged commit (--sha ${{ github.sha }}), which is the post-bump commit, and --pr-fallback cannot rescue it because this repo runs no CI on PRs — gates keyed to pre-bump main leave the v0.24.0 tag publishing nothing. v0.23.0 was done correctly and is the reference: the green sdk-smoke run sits on a047d745, the bump commit v0.23.0 points at orchestrator DONE 2026-09-08. verify_ci_green.sh --sha bf62f20c… → PASS; backbone ci.yml 34263533400 + sdk-smoke.yml 34263538542 green at bf62f20; UI ci.yml 34263760370 + e2e-smoke.yml 34263764435 (stack_control=true) green at bdc6e4c. The UI's e2e-smoke run must be at the UI release SHA — i.e. after its package.json bump commit — for the same reason as the backbone's. Manual gate, this release: the UI's publish.yml has no verify-ci (one job, no needs:, unlike the backbone's release.yml), so a v* tag publishes red or green and deploy-prod.yml's preflight only proves the tag exists; both UI run conclusions were therefore read from the API before tagging. Tracked as ui#396
F-8 ~~R2, coordinated~~ — done 2026-09-08. Ordering corrected: R2 ships in ONE deploy-prod.yml dispatch carrying both tags; if ever split, the backbone goes first. The old rule ("backbone must not ship first — ui#304 mirrors #1215's paged by_exposure") is withdrawn: the UI has never called /v1/portfolios/{id}/aggregate at all (PortfolioDetailPage.tsx:600-603 says so outright; PortfolioAggregate has no call site outside the types), and by_exposure never became {items,total} — it is still a list with an additive sibling by_exposure_total that no UI version references. The real coupling runs the other way: ui#385's matrix/comparison DELETE calls (client.ts:1057, :1130) need #1278's routes, absent from v0.23.0. deploy-prod.yml is a single job whose backbone step (:447) precedes its UI step (:558), so one dispatch already gives the correct order and two would widen the mismatch window. Steps as run: prod pg_dump; backbone version-bump PR (pyproject.toml, src/climate_lama/__init__.py, CHANGELOG.md, uv lock) — no SDK change: sdk/python/climate_lama_client/__init__.py stays at 0.3.0 (set by #1188 / PR #1253; no file under sdk/python/climate_lama_client/ has changed since, which is what makes the package byte-identical — four files elsewhere under sdk/python/ did change, via #1254 and #1312, so "only an example changed" would be false) and no sdk-v* tag is cut in R2 — SDK publication is tracked on #278 and blocked on #6, the unresolved licence choice, since a PyPI upload is irreversible and would ratify it. The 0.24.0 CHANGELOG section is written from git log v0.23.0..origin/main ([Unreleased] is empty — nothing to promote) and states that the Alembic head does not move (still 0091). Then F-7's gates on the merged SHA, then tag v0.24.0; UI bump package.json + package-lock.json — set to 0.14.0 outright, the file has read 0.12.0 since before v0.13.0 — → tag v0.14.0; deploy-prod.yml both images; verify-deploy.sh; /v1/info owner + orchestrator MET 2026-09-08 20:16 UTC. pg_dump 20260908_200719 (602 MB db.dump, service exit 0) via the nightly unit; tags v0.24.0 @ bf62f20c and v0.14.0 @ bdc6e4c4; release.yml 34273199378 and UI publish.yml 34273227387 both success; publish.yml --ref main 34273529947 dispatched so :latest actually moved — a v* tag does not move it (enable=${{ github.ref == 'refs/heads/main' }}), and .env.example/docker-compose.yml default UI_TAG=latest, so skipping it would leave a clean clone on a stale UI (ui#397); one deploy-prod.yml dispatch 34273547720, both tags, backbone and UI steps both success. Verified: verify-deploy.sh v0.24.0 → ✓ ready, ✓ identity bf62f20c956c, version=0.24.0; /v1/info serves 0.24.0 at bf62f20c…. UI identity verified by hand: index-Bp0XTFDJ.js → index-Do85QOvy.js — deploy-prod.yml only liveness-checks the UI (:727-729), its identity step is if: inputs.backbone_tag != '' (backbone only) and verify-deploy.sh has no UI references, so a no-op UI deploy would have passed silently (#1315)
F-9 ~~Pre-walk data check on prod~~ — done 2026-09-08 (read-only API: 3 portfolios answer /members, 0 saved scenarios — the walk creates them; the coverage-undetermined defect found here became #1318). Original: Pre-walk data check on prod: ≥1 non-demo portfolio answers /members with lat/lon/value (the northlane live-event books), ≥2 saved results to reopen and compare; ingest through the real pipeline if not owner walkable data confirmed
F-10 ~~The walk~~ — done 2026-09-09 (owner; nine finding clusters → 15 issues, see "F-10 walk outcome"). Original: The walk: the five questions on prod + showcase Moment 3 steps 1–6 on /assess and /runs (flip the re-walk owed markers to live); findings tagged owner findings recorded
F-11 ~~Remediation loop~~ — done 2026-09-10 (both waves landed, R2.1 live 06:16 UTC, owner re-walk green; #1322 decided pin-drop-only, #1323 closed on the Home re-walk). Original: Remediation loop: any [CRITICAL] → fix, R2.1, redeploy, re-walk the failing question, until zero orchestrator + owner zero [CRITICAL]
F-12 ~~Close~~ — done 2026-09-10: this PR (mid-review written, milestones closed, plan flipped, Phase 15 OPEN). Original: Close: docs/plan/mid-review-phase-14.md (verdict; both rework ratios with the UI analysis; the six exit criteria with evidence); both milestones at 0 open and closed; plan.md banner + status log + phase table flipped; Phase 15 opens (/phase-build 1279 per the Phase 15 split memory) orchestrator Phase 14 CLOSED
F-13 (On the owner's prompt) app-wide sweep for the same defect classes → Phase 15/16 inputs orchestrator —

Critical path: F-2 → F-4 → F-5 → F-8's bump PR → F-7's gates → F-8's tag → F-9 → F-10 → F-11 → F-12. F-7 is inside F-8, not before it — the gate must run on the commit that gets tagged. F-3 ran between F-2 and F-8 (needed the box quiet). F-6 lands after F-7, not after F-4: exit criterion 4's evidence is produced by F-7, so an F-6 that lands earlier either ships without that evidence or has to land twice.

Mid-review inputs (collected during closeout, 2026-09-08)

Raw material for F-12, which the planning session owns. Evidence pointers, not conclusions.

Central finding — checks that reported success without doing their work. Six instances in one phase, all found by reading rather than by any gate:

  1. The gh merged-PR body search as a "has the fix landed?" precondition — fails open: five merged PRs referenced #1237, none of them the fix.
  2. #1307 — probe_branch() printed its expect_terminal argument instead of asserting it. say returns 0, so a failure-branch upload returning succeeded or cancelled still recorded PASS. Fails open, in exactly the direction the B-3′ gate needed teeth. Fixed in #1308.
  3. #1305 — --require-merged spliced its title regex into a jq string literal, so ^fix\( (the flag's own documented example) failed to parse; the error went to /dev/null and || echo 0 reported it as a clean "no merged PR references #1271". Fails closed — safe, but it refused a valid run. Fixed in #1306.
  4. Exit criterion 3 itself — failure.spec.ts skipped on every run for the entire phase because nothing set CL_E2E_STACK_CONTROL. Reported green throughout. ui#387 fixed the lane; its first honest execution failed.
  5. docs.yml green against a 404. The build and upload succeed and the workflow reports success, but cloudflare/pages-action passes branch: main while the Pages project's production branch is not main, so every run lands as a preview at a per-deploy hash alias. mkdocs.yml:3's declared site_url returns 404, so every canonical link, sitemap entry and social card points at nothing — refreshed every weekday by cron. #1314; the fix's first step is a Cloudflare dashboard change, which is why no workflow ever caught it.
  6. The UI's publish.yml has no gate where ADR-046 would lead you to assume one — one job, no needs:, no verify-ci, unlike the backbone's release.yml. "The tag published, therefore it passed" is false in that repo. ui#396.

Adjacent, same family: deploy-prod.yml cannot prove the UI landed — its whole UI post-deploy check is a 200 on /, its identity step is backbone-only, and verify-deploy.sh has no UI references, so a no-op UI deploy passes. #1315. And a v* tag does not move the UI's :latest, while consumers default to it — ui#397.

Proposed rule. Every gate must be shown red by inverting what it claims to check, before it counts — the phase doc's prove-it-fails discipline applied to gates and workflows, not only to tests. It is not hypothetical here: #1307's probe was fixed by re-running it against a case it had to reject, and B-3′ then re-ran the whole pair on the fixed probe so its evidence came from a check that could have failed. The one line worth quoting: criterion 3 was reported met by a check that had never executed; its first execution failed — and the failure was in the check, not the product.

Second finding — issue bodies written from assumed rather than read state. Four instances, all caught before they cost a build: ui#385 and ui#386 cited backbone paths missing src/climate_lama/; ui#386's acceptance told a builder to retarget a portfolio.spec.ts member-removal step that is not in the file; and the cross-repo release rule "ui#304 mirrors #1215's paged by_exposure" described a coupling that does not exist in the code — written from the backbone diff with the consumer assumed. That last one shaped the phase's whole release sequencing until it was checked. The planning session has acknowledged authorship of all four and is correcting #1215's pinned decision.

Third finding — a watcher that dies is indistinguishable from one still waiting. Four background watches in this session were OOM-killed rather than firing; silence reads as "not yet" in both cases, which is the same shape as a check that reports nothing while doing nothing. Both this session and the U8 session independently converged on the same discipline: never trust a watcher's silence, reconcile from server state before acting.

Fourth — scripts/ shell reaches trunk unverified. ci.yml scopes ruff to src/ tests/ sdk/python/, mypy to src/climate_lama/ and pytest to tests/, so scripts/smoke_walk_wsl.sh is inspected by nothing. That is why #1305 and #1307 landed. CLAUDE.md currently names raw-SQL migrations as the only artifact reaching trunk fully unverified; this is a second one. #1267 carries the lint scope into Phase 15.

D15 rework, raw numbers (rework_rate.py --since 2026-09-05 --follow-ups, 2026-09-08): backbone 5 fix PRs / 38 builds = 13 %; UI 9 / 25 = 36 %, over the 25 % trip. Attribution gap, recorded as fact: the UI's unsized bucket is 13 builds with 5 fixes carrying no tier metadata, because the U-wave builders did not stamp it. The planning session's reading of the nine UI fixes — five trunk-red repairs of two now-encoded classes, three composed-defaults fixes, one bounded-wait fix — is its interpretation to defend in the mid-review, not established fact. Re-run both repos at close.

F-10 walk outcome and the F-11 remediation wave (2026-09-10)

The walk (F-10, owner, 2026-09-09, prod v0.24.0 / UI v0.14.0). Home, engine comparison, Datasets and the support surfaces walked clean; the five questions were all answerable. Findings, each root-caused in code before an issue was filed (three read-only investigations + a fast /reassess: 8 of 14 drafts confirmed, 6 corrected, 4 items added; full record C:\tmp\phase14-f11-plan.md):

Finding (walk step) Root cause Issue Tag
Tab title "undefined"; Save scenario near-invisible; modal Save missing (2.1, 2.10) UI loadBrandConfig returns the backbone's 5-key branding payload unmerged; document.title = undefined; THEMES[undefined] sets no CSS vars; bg-brand-primary has no fallback → transparent ui#402 (+ #1321 contract) [CRITICAL]
Run button collapses; "1 cell" note never shows; Baseline single-select unexplained (2.1, 2.4, 2.11) rail footer not shrink-0 inside a scrolling flex column; note gated > 1; baseline exclusivity is by design (no matrix cell for baseline) ui#403 [ENHANCE-NOW]
COVERAGE tile missing after a single run (2.8) result cache served a pre-#1218 row without rows_computed/scope_total; the cache write is ON CONFLICT DO NOTHING, so a plain "miss" would be permanent #1319 [ENHANCE-NOW]
exposure_name/hazard_id banners; batch reopen ticks one cell (2.11, 3.6) UI writes two legacy keys the backbone warns on; save records the anchor cell only and reopen rebuilds the axis from it ui#404 [ENHANCE-NOW]
Lookup: no Region chip; address search unavailable (5.2, 5.6) chip echoes a filter param the UI never sends (dead code); Nominatim profile never enabled on prod (#534 decision of 2026-08-02) #1320 + ui#410; #1322 (owner decision) [ENHANCE-NOW] / decision
Compute panel offers two perils as raw enums; river flood → "nothing to compute" (6.6) panel derives perils from the H3 rollup and runs listHazardCatalog(type)[0] = newest = the sparse JRC raster, never a chosen dataset (#871 re-committed client-side) ui#405 [CRITICAL]
Draw an area does nothing; members table unpaged (6.5) cluster-hover handlers reset the crosshair; the only feedback sits below 216 unpaged rows; draw never disarms ui#406 [ENHANCE-NOW]
Runs: no checkbox on batch/comparison rows; two rows per saved batch (8.1, 8.6) checkbox only on result-bearing scenarios with a hard cap of two; hub concatenates scenarios and groups although inputs.batch_id is stored and both list routes now carry inputs_resolved ui#407, ui#408 [ENHANCE-NOW]
Home coverage grid empty (pre-known from F-9) coverage_groups derives country from hazard_datasets.bbox, which no hazard ingest path writes; the PostGIS footprint exists for exactly this; Home hides undetermined hazards #1318 + ui#409; #1323 (owner: footprint backfill) [ENHANCE-NOW]

Tags are the orchestrator's proposal; the owner delegated the wave ("work on these issues like a phase-build session") rather than tagging. All fifteen issues sit on the Phase 14 milestones (exit criterion 1 findings, admission gate ii); #1322 and #1323 are ops, owner-run.

Wave plan. Backbone (/phase-build 1318 1319 1320 1321 --no-pr-ci --max-runtime 300, this session): #1318 ∥ #1319 ∥ #1320, then #1321 (shares docs/CLIMATE_LAMA.md with #1320). UI (/phase-build 402 403 409 404 405 407 406 408 410 --no-pr-ci --max-runtime 420, a session in climate-lama-ui): #402 ∥ #403 ∥ #409 → #404 ∥ #405 ∥ #407 → #406 (after #405) ∥ #408 (after

407) ∥ #410 (after backbone #1320). Then the R2.1 release: bump PRs (backbone pyproject.toml,

src/climate_lama/__init__.py, uv lock, a [0.24.1] CHANGELOG section; UI package.json + lockfile), SHA-exact gates, tags v0.24.1 / v0.14.1, one deploy-prod.yml dispatch backbone-first, verify-deploy.sh + UI bundle hash; then #1323; then the owner re-walks steps 1.2, 1.5, 2.8, 2.10–2.11, 3.6, 5.2, 6.5–6.6 and 8. F-12 flips the five re-walk owed markers in showcase-script.md to live with the owner's batch, comparison and scenario ids.

Considered, not pursued: a bbox= filter on GET /v1/hazards (footprint coverage does not imply non-zero cells — the JRC case passes it); persisting batch axes into ScenarioInputs (new keys → new banners); aliasing hazard_id server-side (the UI already writes the canonical key).

Outcome (2026-09-10 06:16 UTC). Both waves landed and shipped as R2.1: backbone v0.24.1 (117477e; PRs #1325 #1326 #1327 #1328 + bump #1330; gates ci 34443118215 + sdk-smoke 34443120346 at the tagged SHA) and UI v0.14.1 (e9d0c21; PRs #411–#419, #422 for the tenth issue ui#420 — the portfolio panel was a second writer of the saved-scenario blob — plus bump #423; gates ci 34443056481 + e2e-smoke 34443058870 at the tagged SHA); one deploy-prod.yml run 34444290318; verify-deploy.sh ✓ identity; served bundle index-Do85QOvy.js → index-Qxd715a3.js; Alembic 0091 unchanged; pre-deploy dump 20260910_060634 (600 MB). Post-deploy reads: /catalog/coverage places all 44 hazard datasets (undetermined 0 — no footprint backfill was needed), the lookup card carries location {GRC, Greece}, the branding payload has all nine keys. Closed on that evidence:

1318–#1321, ui#402–#408, ui#410 (ui#420 closed by its build). Still open on the milestones:

1322 (owner decision), #1323 (Home re-walk 1.2/1.5 only). Residue off-milestone: #1329

(env templates), ui#421 (eight builder-proved findings; the maplibre getCanvas() mock that made cursor behaviour untestable in ten test files is a vacuous-gate instance for the mid-review). Remaining for exit criterion 1: the owner re-walks steps 1.2, 1.5, 2.8, 2.10–2.11, 3.6, 5.2, 6.5–6.6 and 8 on prod; then F-12.

Tooling finding (2026-09-10). The builder agent's isolation: worktree was refused in this session ("git identity could not be verified") even after a stale commit-graph entry was rebuilt and git fsck came back clean; the wave runs on the skill's documented fallback shape — an orchestrator-created worktree per issue and a general-purpose agent carrying the builder body, pinned to that path on every command.

Conflict hotspots (files touched by >1 item)

Backbone: src/climate_lama/api/v1/exposures.py (W0.3, W1.1, W1.3), api/v1/hazards.py (W0.11, W1.1, W1.4b), api/v1/compute.py (W0.8, W0.9), api/v1/portfolios.py (W0.4b, W0.6, W0.14), api/v1/results.py (W0.6, W0.7), api/v1/risk.py (W0.4c, W0.5), models/portfolio.py + models/enums.py + migrations/ (W0.1, W0.4a — land serially, heads 0089 then 0091 — 0090 was taken by #1170's upload work in between), core/errors.py + sdk/python/climate_lama_client/errors.py (W0.1, W1.4b), core/notifications.py (W0.1, W0.10), worker/tasks.py (W0.1, W0.4c, W0.7, W1.4a), worker/ingest/adapters.py + validate_source.py (W1.4a), config.py (W1.1, W1.4a/b), core/rollup_service.py (W0.4c, W0.5), docker/*.Dockerfile (W1.1), sdk/python/climate_lama_client/{client,aio}.py (W0.15, W1.3), scripts/smoke_e2e.py (W5.2), docs/CLIMATE_LAMA.md (W0.3, W1.4c, W5.3). UI: src/api/client.ts + src/types/index.ts (W0-UI.0 only — every other UI issue is consumer-only), src/App.tsx (W2.3c, W2.4, W4.1a, W4.2, W4.3), src/components/Dashboard.tsx + ConfigPanel.tsx (W2.1–W2.8, serial inside W2), src/i18n/index.ts + i18n.test.tsx (W2.3c, W4.6), src/test/*.test.tsx (W2.3c, W2.4, W3.1b, W4.6), tests/e2e/smoke/*.spec.ts (W1.5 upload, W2.3c matrix + scenario-matrix, W2.4 agreement, W3.1 portfolio + portfolio-map, W4.2 compare + save, W4.3 the retargeting list).

Exit criteria

  1. Walk: the owner walks the five questions on prod (Home → Assess single and multi-cell → Lookup → a portfolio with assets on the map and a ranked table → Datasets inspector → Runs reopen and compare) with zero [CRITICAL] findings; Moment 3 steps 1, 2, 4, 6 walk on Assess, step 3 saves on Assess and recalls from Runs (/assess?batch=), step 5 walks on Runs. MET 2026-09-10 — the owner walked all nine sections on prod v0.24.0 / UI v0.14.0 on 2026-09-09 (nine finding clusters, two tagged [CRITICAL], all root-caused and shipped as R2.1 the same night — see "F-10 walk outcome" above), then re-walked the affected steps (1.2, 1.5, 2.8, 2.10–2.11, 3.6, 5.2, 6.5–6.6, 8) on v0.24.1 / UI v0.14.1 on 2026-09-10: all green, zero [CRITICAL] outstanding. Moment 3 ids: batch e0bf0155-5686-4189-b6fd-4235ac3acd5e, comparison 82e38d05-38ae-4266-badb-4f294a333af5, scenarios 91fb2637-9b4e-4bcf-b84f-42fcead9c405 / 8cfee746-5cb5-4089-b709-35fa2c4be8e4.
  2. Surfaces: navigation has eight entries; no route renders a permanently empty or dead state; /matrix, /agreement, the /measures nav and the wizard code are gone. MET 2026-09-08 at the UI release SHA bdc6e4c (tag v0.14.0), each clause read from source rather than asserted: nav is a literal eight-entry array (src/App.tsx:38-45 — /, /assess, /lookup, /portfolios, /datasets, /runs, /settings, /admin adminOnly; nav.logout is an action, not an entry); grep -c 'path="/agreement"' = 0 (ui#324 moved the engine picker, the comparison poller and the agreement table onto /assess); the /measures nav entry is gone (ui#311) — the route is deliberately retained, with D9 named in App.tsx as the owner of its deletion; /matrix's page is deleted (ui#323) and the address survives as a redirect to /assess?batch= (MatrixBatchRedirect, App.tsx:200) — stated that way rather than "gone", which would overstate it; wizard code absent (git ls-tree -r --name-only <sha> -- src/ | grep -i wizard empty, ui#316). Exempted (2026-09-10): /lookup's address box answers 503 E_GEOCODE_UNAVAILABLE on prod by the #534 / #1322 pin-drop-only decision. It is not a dead state under this criterion because ui#410 renders an explanatory message and the coordinate entry + map keep the route fully usable; hiding the box behind /v1/info features.geocoding is the Phase 15 follow-up recorded in phase-15-live-risk.md.
  3. Outcomes, in e2e-smoke (tests/e2e/smoke/compose.yml, real backbone): (a) a compute whose staged hazard file is removed → failed state with error_code and a JOB_FAILED row in GET /v1/users/me/notifications (failure.spec.ts); (b) docker compose stop worker before RUN → "still running, see Runs" within the poller budget; (c) a scope with one row outside the hazard extent → stats bar "N of M". Evidence = spec names + run id at the merged SHA. MET 2026-09-08 — e2e-smoke.yml run 34263764435 at the UI release SHA bdc6e4c, dispatched with stack_control=true: failure.spec.ts:314 a missing staged hazard file surfaces the failed state with an error code ✓ 4.8 s, and failure.spec.ts:364 a stalled worker leaves the bounded "still running" state, not a spinner ✓ 10.1 min (the poller budget genuinely expiring) — 2 passed (11.2m), both reported expected by the lane's "Assert the stack-control tests actually ran" guard. This criterion was vacuous for the whole phase until 2026-09-08: failure.spec.ts skipped on every run because nothing set CL_E2E_STACK_CONTROL (ui#387). Its first real execution — run 34240201181 — failed, and the failure was in the check, not the product (a 3-asset compute finished inside docker compose stop's ~10 s SIGTERM grace, so the spec's premise was false); PR #399 made the job un-completable by construction, proven at d3a6bd5 by run 34254033158 and re-proven here at the release SHA.
  4. Machine checks: scripts/smoke_e2e.py carries W5.2's three assertions and sdk-smoke.yml is green; UI tests/e2e/smoke including the contract spec is green at the merged SHA. MET 2026-09-08 — W5.2's assertions landed with #1177; sdk-smoke.yml run 34263538542 green at the backbone release SHA bf62f20 (tag v0.24.0), alongside ci.yml run 34263533400, and scripts/verify_ci_green.sh --sha bf62f20c… returns PASS. UI ci.yml run 34263760370 and e2e-smoke.yml run 34263764435 green at the UI release SHA bdc6e4c, with the .h5 exposure-upload leg (upload.spec.ts:100) executing and passing (✓ 6.0 s) for the first time anywhere — ui#392 un-skipped it, so ui#307 closed without the "unverified .h5 leg" caveat that had been planned. One recorded deviation: "contract.spec.ts checks them" is met via the sibling contract-lifecycle.spec.ts driving the same COVERED entries (ui#315 / PR #400) — accepted as the stronger check, since a red spec elsewhere cannot take the whole-api partition assertion down with it.
  5. Prod hygiene: demo residue = 0; portfolio_assets = 0 recorded (W0.0). MET 2026-09-08 — residue deleted by the owner, API proof on #1193 (comment 5586155467); portfolio_assets = 0 recorded on #1192 and re-read unchanged after the deletions.
  6. Record: docs/plan/mid-review-phase-14.md with a verdict and tagged findings; plan.md banner and status log flipped. MET 2026-09-10 — mid-review-phase-14.md, verdict Ready for next phase; banner, status log and phase table flipped in the same PR; both milestones closed at 0 open.

Milestones. Phase 14: 0 open in both repos, verified 2026-09-08 18:28 UTC (gh issue list --milestone "Phase 14 -- Solidify" --state open empty in climate-lama and climate-lama-ui). Every deferral is carried on a Phase 15 milestone rather than dropped — including ui#377, which stays open with items 1 and 4 struck on evidence and items 2, 3 and 5 outstanding (item 2 needs a backbone change first: JobListItem carries no portfolio id).

Out of scope — the Phase 17 charter stub (was "Phase 15" until 2026-09-08, "Phase 16" until 2026-09-11)

Renumbered 2026-09-11. Phase 16 — Consistency took the next slot; the deferrals below are now the Phase 17 stub.

Renumbered 2026-09-08. The owner defined Phase 15 — Live Risk in the next slot; the deferrals below are the Phase 16 stub. #1240, which the residual triage deferred "to Phase 15", is admitted there as-is (W0.7).

Deferred deliberately, each with the reason it is not solidification (mirrored in ui-revamp.md "Phase 15 charter stub"):

  • Visual revamp of every surface (ui-revamp.md) — design work.
  • Adaptation measures: create/edit UI, seeded catalog, cost-benefit with a climate-adjusted future hazard (adapter flaw recorded in D9) — new user capability.
  • Stochastic event-set hazards (> cap, dated multi-event catalogues) with an event-set store — new storage model.
  • GeoTIFF exposure upload through the API (adapter exists, operator-only).
  • Per-asset impact-function binding from CLIMADA impf_* columns — the impact-function model has no per-asset binding; Phase 14 keeps impf_* in properties untouched (W1.3).
  • Engine selector beyond climate_lama + CLIMADA sidecar; a third engine.
  • Physical DROP of portfolio_assets / assets (W0.5 retires the read path).
  • Country onboarding beyond Greece and Cyprus (#447).

Process amendments recorded here (framework requires the phase file)

  • Circuit-breaker and PR typing per D15 / guardrail 9. Weekly metric unchanged: the walk passes, a question visibly improves.
  • /phase-split runs once per repo (worktree isolation never cuts a sibling repo); backbone first. Cross-repo blockers are written fully qualified in the Deps: line as CortoMaltese3/climate-lama#N — a bare #N resolves inside the UI repo — and the UI wave is not dispatched until the named backbone issues are merged. W0-UI.0 is the mandatory first UI issue; every other UI issue is consumer-only and lists it.
  • Release cadence: pg_dump before each release; one mid-phase release after W0+W1 (the upload matrix and failure visibility are user-visible fixes), one at close.
  • Milestone Phase 14 -- Solidify in both repos, label milestone:phase-14.