Skip to content

Phase 9 — build execution plan (waves over #583–#625)

Companion to docs/plan/phase-9-data-foundation.md. The split (2026-08-03) produced 43 backbone issues + ui#100/#101. This file is the operating plan for landing them: what runs in parallel, what serializes and why, and the per-wave mechanics. Update the wave checkboxes as waves land; each wave-session starts by reading this file.

Ground rules (every wave)

  1. FAST MODE (owner decision, 2026-08-04). The phase is built unattended, back-to-back, with verification deferred: sessions run under the --ghost protocol, builders never wait on CI, and the full test/CI pass happens once multiple/all waves are on main (see "End-of-phase verification"). This consciously overrides, for Phase 9 only and by owner decision, the CLAUDE.md rule that each migration merge warrants its own ci.yml dispatch — an unattended session must NOT "correct" the cadence back. Prod is protected regardless: releases stay gated on a SHA-exact green run (ADR-046), so nothing unverified can ship.
  2. Sessions run back-to-back, one at a time. A wave boundary is a session/limit boundary, nothing more. Start every session with the state check (git fetch origin main, open-PR list, gh issue list --label milestone:phase-9 --state open, git worktree list) — several sessions share this repo. Never run two orchestrating sessions concurrently; they will fight over the migration head and hotspot files.
  3. Cap concurrency at ~4 builders. Session limits were hit twice on 2026-08-03; four parallel worktrees is the sweet spot between throughput and burn. Schedule the Opus issues (#589 #593 #594 #595 #596 #598 #600 #604 #609, #621=max) early in a fresh limit window.
  4. Migrations serialize at merge, always. Every migration-bearing issue conflicts on the Alembic head chain. Build in parallel worktrees if files are otherwise disjoint, but merge migration PRs one at a time, rebasing the revision id onto the new head. Typed Alembic ops only — in fast mode a migration reaches main with NOTHING having executed it (no local Postgres, no CI until the checkpoint), so the 2026-08-01 raw-SQL lesson applies double.
  5. CI cadence: two dispatches for the whole phase. (a) One migration checkpoint ci.yml dispatch after the last migration-bearing frontier lands (end of wave 3) — migrations chain linearly and are CI-only-verifiable, so a broken one discovered at phase end would poison every revision built on top of it; one ~1-minute run bounds that blast radius and no builder waits on it. (b) The end-of-phase verification pass. Nothing else — no per-wave, no per-merge dispatches.
  6. Targeted tests only, in-flight. Builders run each issue's targeted pytest per its acceptance checks — never the full ~1264-test suite mid-build. The full suite runs once, at end-of-phase verification.
  7. Stretch stays out. #606, #607, #623, #624, #625 keep their stretch label and are excluded from waves 1–5. Pulling one in is an explicit owner decision.
  8. Ops is a separate track, not a build. Pack issues have a buildable half (tooling, manifests, fixtures) and a manual ops half (real downloads → Hetzner raw zone → prod ingest). Ghost sessions never mutate prod — ops runs happen owner-side (WSL→ssh) as each pack's code merges. Start CDS/EWDS license acceptances early; the queues are the long pole of the phase.
  9. Four perils are enum-only until wave 3 — tests/hazard_wiring.py is the register. #599 added coastal_flood, heatwave, earthquake and drought to hazard_type_enum in wave 1, but their wiring is wave 3. UNWIRED_HAZARD_TYPES in tests/hazard_wiring.py names each one beside the issue that wires it (#602 / #603 / #604 / #606), and every "each hazard type is covered" test derives its expectation from that one map. The guard stays live: the wired set is the derived complement, so a new HazardType member is held to full coverage automatically. If a wave ever adds another enum member ahead of its wiring, add it to that map with its issue number — never weaken or skip a coverage test instead. tests/test_hazard_wiring.py fails if a listed peril turns out to be wired, so deleting your own entry is part of wiring the peril. (#657 → PR #658.)

Wave 0 — owner, no code (can run any time, gates the phase exit)

  • [x] Six-moment Showcase walkthrough on prod — PASSED, on the owner's report (2026-08-10). #550 is closed on that report, not on any agent-side verification: no session observed coverage.considered == N. Prod at the time ran v0.11.0 = 339f2cd (GET /v1/info, checked 2026-08-10). The baseline-column caution below is discharged — #631's fix (cdcc6f4, PR #644) is contained in v0.8.0 and every tag since, so the 2×3 SSP grid's baseline column is safe to read. Superseded detail, kept for the record: the box originally read "prod runs v0.7.0 backbone + ui v0.4.0 as of 2026-08-04" and warned not to read a baseline column until #631 landed. Moment 4 drives the 2×3 SSP grid (ssp2-4.5 / ssp5-8.5 × 2030/2050/2080).
  • #558 no longer rides this walkthrough — but check that was intended. It was closed 2026-08-04T15:14:45Z by CortoMaltese3 with no closing comment and no linked commit, 3.5 minutes after its own last status comment recorded acceptance items 3–4 as still riding this walkthrough ("no matrix run has ever executed against production"). The close may be deliberate; nothing in the record says so. If the walkthrough's matrix moment fails, reopen #558 rather than filing a new issue.

Wave 1 — foundations (7 issues)

Parallel batch (disjoint files): #583 (identity columns, migration), #586 (storage keys + ADR-049), #597 (real DatasetSource URLs — needs outbound network for fixtures), #598 (CDS/EWDS client, L), #605 (OpenQuake spike, docs), #609 (SDK risk-API backfill, L).

Serial tail: #599 (enum members — merge after #583 lands the head), then #592 (IngestAdapter ABC — after #586; both touch aggregate_and_commit.py).

  • [x] Wave 1 landed 2026-08-05 — all 8 issues merged: #583→PR #646, #609→#647, #605→#648, #599→#649, #598→#650, #586→#651, #597→#654, #592→#655; trunk 2295369. Two follow-ups off this wave, both landed: #652/#653 (RLS org GUC dropped after compute_impact's mid-task commit) → PR #656, and #657 (14 coverage tests red — #599's enum members outran their wiring) → PR #658, trunk 358674b, suite back to 0 failed. See ground rule 9 before touching a hazard-coverage test.

Wave 2 — second-order contracts + mechanism (6 issues)

#584 (exposure_datasets ←583), #585 (taxonomy ←583), #589 (dataset_id into surfaces, L), #600 (indicator-peril mechanism, L), #595 (out-of-core aggregation, L ←592 file-wise), #593 (NetCDF ←592).

Constraints: #589 and #600 both touch lookup_service.py → serialize those two (build both, merge #589 first, rebase #600). Migration merge order: #584 → #585 → #589.

  • [x] Wave 2 landed 2026-08-05/06 — all 6 issues merged: #595→PR #663, #584→#664, #593→#665, #585→#666, #589→#667, #600→#670. The first ci.yml dispatch covering wave 2 came back RED: two defects in migration 0070 (#584/#664), both invisible to lint/mypy/the local non-DB suite — func.min(exposures.org_id) (Postgres has no min(uuid)), fixed by PR #672, and a new FK with no parent-row writer, filed as #673 and fixed by PR #679. Further fixes landed as #682, plus #684 as residue.

Wave 3 — consumers (10 issues, two sub-batches)

3a parallel: #587 (manifests ←586), #588 (catalog producer ←583/584), #590 (risk selector ←589/583), #594 (raster→exposure ←592/584), #596 (retire centroid rows ←595),

601 (WF-FWI indicator ←600).

3b serial chain (shared impact_function_seeder.py + layer_spec.py + migrations + tests/hazard_wiring.py): #602 (coastal) → #603 (heatwave) → #604 (earthquake, L) →

621 (Huizinga re-derivation, Opus·max — license-boundary judgment). These were run as

a stacked chain so the PRs did not fight over the seeder. (Historical note: this line originally called /build-chain 602 603 604 621. That skill no longer exists — it was deleted and its batch behaviour folded into /analyze. Serialise a hotspot chain with successive /build N calls instead.)

Per ground rule 9, #602, #603 and #604 each delete their own UNWIRED_HAZARD_TYPES entry from tests/hazard_wiring.py as part of wiring — that file is a fourth hotspot for this chain, which is one more reason to stack rather than parallelise. #606 (drought, stretch) owns the remaining entry whenever it is pulled in; until then the register is what keeps the coverage guards green without switching them off. #601 (WF-FWI) has no entry — wildfire was already wired before #599.

  • [x] Wave 3 landed · MIGRATION CHECKPOINT: ci.yml dispatch ran. 3a: #594→PR #668, #588→#669, #587→#671, #601→#674, #590→#675; #596 closed on reconcile with no closing-PR reference (its work landed in PR #681, which lacked a Resolves footer). 3b serial chain: #602→#676, #603→#688, #621→#689, #604→#691.

Wave 4 — surface + resolver (4 issues)

#591 (matrix pinning ←590), #608 (catalog browse ←583/584/585), #610 (SDK selector ←609/590/608), and file the climate-lama-engine issue for the CF/EQ hazard codes if wave 3 didn't (referenced by #599/#602/#604). ui#100/#101 unblock after this wave — they build in the UI repo, not here.

  • [x] Wave 4 landed — all 3 issues merged: #608→PR #693, #610→#694, #591→#695.

Wave 5 — pack code halves (11 issues)

#611 first (creates the volume-budget ledger every other pack appends to), then parallel: #612, #613, #614, #615, #616, #617, #618, #619, #620; #622 last (←611). docs/data/volume-budget.md is a shared file — trivial merge conflicts, just rebase.

  • [x] Wave 5 landed 2026-08-06/07 — all 11 issues merged: #611→PR #700, #612→#701, #613→#705, #614→#712, #615→#707, #616→#709, #617→#714, #618→#702, #619→#704, #620→#703, #622→#713; plus the deferred ledger Totals row →#715. No Alembic migration in the whole of wave 5 — head stayed 0078. The plan's "#611 first, then all nine in parallel" was wrong in an instructive way: the five hazard-pack issues (#611/#612/#618/#619/#620) each listed src/climate_lama/ingest/sources/ in their Files line, implying a 5-deep serial chain on that directory, but CopernicusCdsSource is driven by an operator-declared retrieval registry parsed from settings/env — registering a CDS/EWDS retrieval is CONFIG, not code, and SOURCE_CLASSES was never touched by any wave-5 pack. Separately, docs/data/volume-budget.md's pre-stubbed rows sit on consecutive lines, so concurrent builders editing different rows still conflicted on git's 3-line diff context (#616 and #614 both had to rebase) — the doc's "trivial merge conflicts, just rebase" claim was accurate in effect but understated the frequency.

Known gaps at wave-5 exit

  • #706 — NetCDF ingest built but unreachable from the dispatch path. CLOSED (PR #717, per-job source_format override). It was the first of six successive blockers on the OPS half of #614/#615/#616 (all CDS-only, hence NetCDF-only); all six are now closed — #706, #761's two worker-side gaps (ADR-064), #782 (PR #794), #791 (PR #798, ADR-067), #793 (PR #800, ADR-068), #797 (PR #804, ADR-069). SUPERSEDED 2026-08-10 — the paragraph that followed is kept verbatim because it is the record of what was believed, but every clause of it is now false: "Those ops halves are nevertheless still blocked, now by an evidence gate rather than a code gap: no NetCDF ingest has ever run end to end, so assert_netcdf_adapter_wired refuses every dispatch and #761 stays open to track it." What is true instead: the first end-to-end NetCDF ingest ran against prod on 2026-08-10 (hazard_datasets c83be4f5-5bb1-4bef-82d8-a100061163cc), which satisfied the gate; PR #825 (42f2a35) then deleted layer 2 of assert_netcdf_adapter_wired, its one-shot --first-end-to-end-run escape and the ADAPTER_GAP_ISSUE_URL constant, and #761 is CLOSED. The surviving guard is the plan-time adapter-resolution check only and refuses nothing that resolves to the right reader. All three ops halves then ran: #615 and #616 are fully ingested, #614's EWDS half is ingested and its C3S half needs only rotated-pole reprojection (the ZIP-per-experiment blocker is fixed by ADR-071/PR #822; PR #819 added the grid guard). See phase-9-ops-plan.md's OPS-4 block and volume-budget.md for the dataset ids and measured volumes. Read NETCDF_INGEST_BLOCKERS in scripts/packs/_wildfire_fwi_common.py on trunk — never a remembered issue number. GeoTIFF packs are unaffected.
  • #708 — LISCOAST turned out to be irregularly-spaced coastal transect point CSVs, not a raster; no ingest path takes scattered points, so #613 shipped its Aqueduct-coastal half only.
  • #550CLOSED 2026-08-10 on the owner's report that the wave-0 walkthrough passed against prod (see Wave 0 above). It was never a code gap: every engineering acceptance item landed in PR #570 (ADR-047, union rollup) plus ui#99, and all three are asserted by DB-backed tests in tests/test_api/test_risk_portfolio_uploads.py (coverage.considered == N, uploaded rows in worst_members with member_kind == exposure, and the stated-partial case), green in CI run 31128301400. The wave-0 prod walkthrough was the only thing outstanding on it, and it has now passed. Do not route this issue to a builder; there is nothing left to build.
  • Stretch set still excluded: #412, #606, #607, #623, #624, #625.

Ops track (owner, interleaved from wave 3 onward)

  • [x] CDS + EWDS per-dataset license acceptances (portal, manual) — DONE by the owner 2026-08-07. Recorded in phase-9-ops-plan.md (header), which supersedes both this box and the "still need their licence acceptances" line in phase-9-full-catalog-walkthrough.md. Corroborated by a real authenticated EWDS retrieval succeeding on 2026-08-09: cems-fire-historical-v1, jobID fd5475dd-c034-4ff5-a696-83e8314a3952, 201 → completed, application/netcdf (see #802 and the #614 row in volume-budget.md) — an unaccepted licence would have been rejected at submission. Do not re-ask the owner about Copernicus licences.
  • [ ] One live polling→review→approve→ingest cycle documented (#597 manual check — exit criterion)
  • [ ] Pack downloads → raw zone → prod ingest, per pack as its code merges (budget row first)
  • [ ] Storage bill vs volume-budget ledger check (exit criterion)

Execution runbook for the items above: docs/plan/phase-9-ops-plan.md (supersedes ground rule 8 for the pack-ops waves — see that doc).

Mechanics per session (fast mode)

Invocation: /phase-build phase-9 --ghost --rr — phase-build owns partitioning, worktrees, and landing; --ghost makes the session unattended (no mid-run questions: reversible calls are made and logged, owner-only steps deferred to a "Run these yourself" block); --rr is subsumed by ghost's reassess rule. /forge phase-9 --ghost routes to the identical command (issues exist, so forge skips its planner) — use forge as the front door for new things discovered mid-phase instead: /forge "bug: <what broke>" --ghost turns a wave's fallout into a routed reproduce-first /fix without ceremony, and keeps discoveries out of the wave issues' scope.

  • One invocation per session; ~one wave per session. Do NOT attempt the whole phase in one run — context and limits won't hold it. phase-build keeps building as deps merge, so a run that clears its wave and rolls into the next frontier is fine — free speed — EXCEPT at the wave-3 boundary, which is HARD: the session covering wave 3b must stop after the 602→603→604→621 chain lands, dispatch the migration-checkpoint ci.yml, and not start wave-4 issues (write this into that session's prompt). The next session checks the checkpoint result first and fixes forward if red. Waves 4 and 5 may fuse into one session if capacity allows — no checkpoint sits between them.
  • Bound each ghost run deterministically with --max-runtime sized to its wave — nobody is watching to stop an unattended run manually.
  • If a session dies mid-wave, the next rerun reconciles half-states (open PRs, dirty worktrees) before building anything new.
  • Ghost rule: subagents inherit nothing — the orchestrating session must carry rules 4/6 (migration merge order, targeted-tests-only) into every builder prompt it writes.
  • Escape hatch for hotspot chains: successive single /build N calls, serialised — one lands before the next starts, so they stack instead of colliding on the shared file. (The /build-chain skill this line used to name has been deleted; do not try to invoke it. /analyze N1 N2 … sizes a batch, but it does not build or stack anything.) A single /build N also remains the tool for a straggler.
  • NEVER add the ci label to any PR; land-wait merges on mergeable (NO-PR-CI).

End-of-phase verification (the deferred bill, one session)

  • [x] ruff + mypy green on 8be5770 (2026-08-07). uvx ruff@0.16.1 check → "All checks passed!"; uvx ruff@0.16.1 format --check → "467 files already formatted"; mypy → "Success: no issues found in 213 source files". Both ruff commands run at CI's exact scope (src/ tests/ sdk/python/not scripts/) and CI's pinned version. The two other cheap CI steps also pass locally: cmp LICENSE sdk/python/LICENSE and uv lock --check.
  • [x] Full local suite run on 8be57703 failed, 2123 passed, 45 skipped, 684 errors in 596s. The 684 errors are the documented no-Postgres baseline, not failures. All 3 failures are local-environment artifacts and each PASSES in CI on this same code (verified by name in the run-31128301400 job log, not inferred):
    1. tests/scripts/test_windstorm_c3s_wisc_pack.py::test_register_netcdf_layouts_is_idempotent — a real forward-compatibility defect, filed separately. NetCDFDatasetSpec is a @dataclass(frozen=True) whose nodata is NaN for the WISC pack, so idempotent re-registration depends on nan == nan being true. It is, on CI's Python 3.12, because dataclass __eq__ compared field tuples and CPython's tuple compare short-circuits on identity. Python 3.14 compares fields directly and loses that shortcut. Measured locally: astuple(a) == astuple(b) is True while a == b is False, with a.nodata is b.nodata also True.
    2. tests/test_core/test_config_security.py::test_dev_allows_placeholders — not hermetic. The repo's gitignored .env sets APP_SECRET_KEY=dev-only-not-a-secret, which Settings reads; the test asserts the built-in placeholder. CI has no .env.
    3. tests/scripts/test_assert_deployed_contains.py::test_exits_nonzero_and_distinct_when_git_sha_unknownsubprocess.TimeoutExpired at 30s shelling out to scripts/assert_deployed_contains.sh through C:\Program Files\Git\bin\bash.exe. Windows Git-Bash process startup under CPU contention (a second agent's suite was running); no equivalent cost on Linux. Standing caveat: the local suite is not a substitute for CI. It reaches ~72% of the suite (2171 non-error local items vs 2748 in CI) because every DB-backed test errors out. The CI run below is the authoritative gate.
  • [x] gh workflow run ci.yml --ref main green (executes every migration from scratch) — run 31128301400 on trunk 146f384 completed success, 17 steps (full lint + format + mypy + the complete 2748-test suite, executing every Alembic migration from scratch). Verified at job level, per ADR-046's rule that a run of skipped jobs is not a tested run: Checks is completed/success with 17 steps; Slow tests (memory-bound, opt-in) is skipped by design. Every commit since is docs-only (146f384..8be5770 touches docs/plan/ alone), and no test reads docs/ — the only test that reads a markdown file is tests/test_changelog.py, and it reads CHANGELOG.md only. Confirming dispatch on the phase-exit HEAD: run 31128819605 on e8ebbc0completed/success, Checks job 17 steps (2026-08-07). That HEAD also contains #717, the concurrent session's #706 fix, which landed mid-pass. Only this line's own record commit sits above e8ebbc0, and it is docs-only for the reason stated above, so no further dispatch was spent on it.
  • [x] sdk-smoke.yml green — DISCHARGED 2026-08-07. Run 31149358628 on d4f46d1, End-to-end smoke success, 19 steps (genuinely ran, not skipped) — see the Release state table below. Both defects the dispatches uncovered are closed: #721 (PR #726, national GADM admin-0 Greece boundary) and #729 (closed 2026-08-07). Read the rest of this bullet as a historical trail, not as current status — it was written top-down as the dispatches happened, so its earlier paragraphs say "still RED" and "blocks the v0.9.0 release". Neither holds; v0.9.0 shipped, and prod has since moved on twice more (v0.11.0, below).

    Historical trail (2026-08-07) — superseded

    At the time this was genuinely outstanding, and the gap was #610 specifically. The newest green smoke was run 31008099833 on 5617168 (2026-08-05), which does contain #609 (PR #647) but not #610 (PR #694, the wave-4 SDK selector) — confirmed with git merge-base --is-ancestor. So the selector surface has never been smoke-tested. That run is also real, not vacuous: its End-to-end smoke job is completed/success, not skipped, so the SDK_SMOKE_* credentials were present (the #470 fail-closed split). One dispatch on the phase-exit HEAD settles this; this is the plan-doc-authorized exception to CLAUDE.md's "smoke is a release gate only" rule. Dispatched 2026-08-07 on e8ebbc0 — run 31128820087 came back RED, and it caught a real regression rather than a flake. Detect smoke credentials passed (so the smoke genuinely ran); End-to-end smoke failed at POST /v1/hazards/ingest -> 422 E_VALIDATION: Unknown region 'Greece', which made scripts/demo.py --seed-only exit 1. Cause: #666 (9797f0c) added region validation against admin_boundaries, and seed_demo seeds only the Attica boundary while dispatching its ingest with region "Greece" — the seeder arms a guard that then rejects its own next call. 9797f0c is not an ancestor of 5617168, which is precisely why the previous smoke was green. Filed as #721 with the fix options; it wants a decision (seed a national boundary vs. relabel the demo surface), so it was not patched blind.

    #721 is FIXED and the fix is verified working (PR #726 — seeds the national GADM admin-0 Greece boundary, so the region the seeder ingests under is one it loads). Smoke run 31130897341 shows step [2] now OK ... completed in 5.4s; the 422 is gone.

    The smoke is still RED, on a second and different defect that #721's fix merely made reachable — the run had never got past step 2 before. It now fails at step [7]: GET /v1/impact-functions?haz_type=river_flood returns an empty list immediately after POST /v1/impact-functions/seed logged "Seeded 12 built-in impact functions", so demo.py --seed-only dies on functions[0]IndexError. Filed as #729, with #689 (Huizinga curve re-derivation, migration 0077) and #710 (refuse built-in curves aimed at the wrong exposure type) as the named suspects — both landed after 5617168, the last fully green smoke. #729 was then the only thing between trunk and a green phase-exit gate, and it blocked the v0.9.0 release under ADR-046. It was fixed and closed the same night; the smoke went green on d4f46d1 and v0.9.0 shipped.

    - [ ] If red: fix-forward on main, newest-suspect-first; git revert per the CLAUDE.md protocol only if a fix isn't quick. Budget a real session for this — it is the price of the deferral, paid once instead of 5+ times. - [ ] Then the Full-Catalog walkthrough (phase exit) and, when releasing, scripts/verify_ci_green.sh --sha $(git rev-parse HEAD) before any tag (ADR-046).

Release state — prod runs v0.11.0 = 339f2cd (checked 2026-08-10)

Current, verified this run: curl -fsS https://api.climate-lama.online/v1/info{"version":"0.11.0","environment":"prod","git_sha":"339f2cdf2f2598be7a0245779cb59b37181f82c3"}.

Prod is BEHIND trunk, deliberately — do not read this as "prod matches main". Trunk is d4b2cc5 and 339f2cd is an ancestor of it (git merge-base --is-ancestor exit 0), with 7 commits on main not deployed: PRs #798, #800, #803, #804, #806, #807, #809 — i.e. the whole #791/#793/#797/#802/#803/#805 NetCDF-and-baseline fix run. Anything testing that surface against prod is testing code that is not deployed; re-check /v1/info and git merge-base --is-ancestor <fix-sha> <deployed-sha> before assuming otherwise.

v0.9.0 — shipped and deployed 2026-08-07 05:29 UTC (superseded by v0.10.0, then v0.11.0)

v0.9.0 = d4f46d1 carried the whole Phase 9 code half. Its gates, kept as the release-gate worked example:

Gate Evidence
ci.yml run 31149330231 on d4f46d1Checks success, 17 steps
sdk-smoke.yml run 31149358628 on d4f46d1End-to-end smoke success, 19 steps (genuinely ran, not skipped)
Local pre-tag gate scripts/verify_ci_green.sh --sha d4f46d1PASS, 0 billed minutes
release.yml run 31150185847verify-ci + both image builds success
deploy-prod.yml run 31150397303 — every step green, incl. alembic upgrade head (00700078) and the at-head assertion

UI shipped separately as v0.5.0 on 2026-08-07 12:23 UTC, closing the gap the backbone deploy left open (that deploy passed ui_tag empty, so the UI stayed on v0.4.0 and ui#100/#101 were absent — this is the note that used to record it, and the Full-Catalog runbook's blocker 1 no longer applies).

Gate Evidence
UI ci.yml run 31177206148 on 0589dfbbuild success (job present, not skipped)
API contract every field the new UI reads (provenance, eai_grade, eai_grade_note, dataset, dataset_id, provider, upstream_version, indicator) is emitted by the deployed backbone at d4f46d1 — checked in core/lookup_service.py and core/rollup_service.py; dataset_id is accepted on all four /v1/risk rungs
UI publish.yml run 31177798315 on tag v0.5.0 — GHCR image built and pushed
deploy-prod.yml run 31177920318 — UI-only; backbone/migrate step skipped, backbone untouched at v0.9.0
Live docker psclimate-lama-ui:v0.5.0, UI_TAG=v0.5.0; served /assets/index-*.js contains Screening-grade EAI, Auto (recommended), Indicator (not lossable), eai_grade, dataset_id

v0.5.0 carries ui#100 (dataset picker per hazard), ui#101 (dataset provenance chip), ui#109 (eai_grade screening-grade label) and ui#107 (tour behind TOUR_ENABLED). Rollback: re-dispatch deploy-prod.yml with ui_tag=v0.4.0 — the prune step keeps the previous image on the host, so no re-pull is needed.

A UI-only deploy verifies far less than a backbone one. Verify deployed identity is gated on backbone_tag and skips, and the Health gate only asserts HTTP 200 — which a healthy old container also passes. A green run therefore does not prove the UI container swapped. Confirm with docker ps plus a grep of the served bundle, as in the last row above. Also note the UI's publish.yml has no verify-ci job: unlike ADR-046 on the backbone, nothing mechanically stops a red UI commit being tagged, so the green-CI-on-this-exact-SHA check is manual discipline.

deploy-prod.yml takes its own backup (keeps 10), migrates, health-gates, verifies deployed identity and prunes superseded images — the manual pre-migration pg_dump from the older deploy recipe is not needed. This repo cuts no GitHub Release objects (only v0.1.0/v0.4.0 have one), so the tag + GHCR images + CHANGELOG are the release; GitHub consequently still badges v0.4.0 as "Latest".

Carry-forward risk: #729 is closed but was never root-caused, and it is INTERMITTENT. (2026-08-10: still the standing advice. #729 remains closed and has not recurred; the sdk-smoke gate has separately flaked once since, on #799 — re-run the same job on the same SHA before calling either a regression.) git diff b23b236 d4f46d1 over src/ and scripts/ is empty — the identical product code failed the smoke, then passed it. The failure breaks scripts/demo.py --seed-only, which is the seeded-demo path the wave-0 walkthrough depends on. If a walkthrough comes up with no seeded data, suspect this first and reopen #729 rather than filing a fresh issue. PR #732 added the guarding tests, so a recurrence should now surface in ci.yml rather than only in a smoke run.

Phase-exit status — resynced 2026-08-10 against trunk d4b2cc5

NOT closed. The build is done; one walkthrough and part of the ops track are not.

All 43 wave issues are merged, and the code half of Phase 9 is finished. #706 closed (PR #717) and #550 closed 2026-08-10 on the owner's report of the wave-0 walkthrough.

The two "hard blockers" this section used to list are both discharged:

  1. ~~sdk-smoke is RED on trunk (#721).~~ Discharged. #721 closed (PR #726), and the follow-on #729 closed too; the smoke went green on d4f46d1 (run 31149358628).
  2. ~~Prod is 46 commits behind trunk with none of waves 4–5 (v0.8.0, 5617168).~~ Discharged. Waves 4–5 shipped in v0.9.0 and prod has since moved to v0.11.0 = 339f2cd (verified 2026-08-10). Standing, smaller version of the same caution: prod is still 7 commits behind trunk — see Release state above. That gap is the NetCDF / baseline fix run, not the catalog/pack surface the Full-Catalog walkthrough exercises. (Retained finding: v0.8.0 already contained the #631 fix — cdcc6f4/PR #644 — so the wave-0 baseline-column warning was discharged then and remains so.)
Exit criterion State
Wave-0 six-moment Showcase walkthrough on prod PASSED 2026-08-10 — on the owner's report; closed #550
Full-Catalog walkthrough (the phase's verifiable goal) outstanding — owner-only; owner running it separately. All six steps are now walkable: step 4's criterion was amended 2026-08-11 (≥2 hazards → river flood's full 3 × 3 grid) because no second hazard can qualify without new science — rationale, evidence and reinstatement condition in the walkthrough doc; closed #836. Step 1's "no blanks" was clarified to name the three honest blanks ADR-067 deliberately produces
One live polling→review→approve→ingest cycle (#597) Re-run live on v0.14.0 2026-08-12. #830 is FIXED and proven; the blocker moved to #865. poll ✅ an on-demand dispatch queued 4 rows, 3 of them copernicus_cds items the poll discovered on its own. reviewGET /v1/admin/pending-ingests → 200. approve ✅ → approved, handoff: dispatched. ingest ⚠️ now reaches real work — stage_source staged 9 rasters (2.67 GiB) in 200 s and validate_source passed them — then plan_chunks refused with E_INGEST_PLAN_REJECTED: the JRC source is full-continent (110162 × 51992 = 5.7 × 10⁹ cells against a 10⁹ ceiling). Typed refusal, no chunk rows, no partial dataset. Clipping to the Greece bbox gives ~1.03 × 10⁸ cells (~1.8% of the ceiling), so #865 alone unblocks this — #446's out-of-core aggregation is not required. Note the earlier 22-row queue is gone: all were rejected on 2026-08-11 as correct triage (nothing was ingestable pre-#830), and a rejection is absorbing — see #870
Storage bill vs volume-budget ledger check DONE 2026-08-08 (OPS-5d): bucket 0.325 GB vs the ledger's ≈24.6 GB transient raw estimate — expected, and ratified by ADR-070. Re-measured 2026-08-10 after OPS-4 and the trash/ purge: 158 objects / 359.87 MB vs a recomputed ledger raw sum of ≈24.87 GB — same gap, same reason
CDS/EWDS licence acceptances → pack downloads → prod ingest DONE 2026-08-10 — supersedes the "partial … 7 of 10 ledger rows are ingested; the 3 remaining are blocked on the assert_netcdf_adapter_wired evidence gate (#761, open) and on OPS-4b's CDS-form inputs" that stood here. Licences accepted 2026-08-07; all 10 ledger rows now read ingested, two of them partially by the ledger's own vocabulary — #614 (EWDS half; C3S half needs rotated-pole reprojection) and #615 (C3S half; WISC not retrieved, because its process schema publishes no spatial input at all). Both cited blockers are gone: #761 is CLOSED and the evidence gate was deleted by PR #825, and OPS-4b's CDS-form values were resolved and its retrievals delivered

Runbook and live status for the ops rows: phase-9-ops-plan.md.

The earthquake cross-repo gate is TRIPPED AND SATISFIED — no longer outstanding. #617's earthquake data must not reach prod before climate-lama-ui#109 (render eai_grade / eai_grade_note), or the UI presents screening-grade EAI as if it were computed-grade. Both halves have now happened, in the safe order: ui#109 closed 2026-08-07, and the ESHM20 dataset reached prod on 2026-08-10 (1 active row, scenario = baseline, 4 ESRM20 curves). Nothing further is owed here — do not re-raise it as a blocker.

Do not flip the plan.md banner to "Phase 9 closed" on the strength of the merge count. The phase's stated goal is a walkthrough, not a milestone tally; Phase 8 already exited once on a waived walkthrough criterion and carried four defects forward.