Skip to content

Phase 15 — Live Risk: consumed history (archive)

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

Ground truth at open (verified 2026-09-08 against backbone trunk e39bb76, UI trunk bc2adb0; re-verified later the same day against backbone b683d9e and UI 32815e6 — every symbol below was grepped on those SHAs; line numbers are as of that pass and rot, symbols do not)

Fact Where Consequence
A watch is live_events(org_id, portfolio_id?, haz_type, name, status open/closed, started_at, ended_at, source, provenance) — no area of interest, no exposure-dataset selector, no creator, no feed src/climate_lama/models/live_event.py:24-72 A feed needs an AOI to fetch for and a selector to score against; migration 0092 (D6)
Snapshots are ordinary hazard_datasets rows with live_event_id + valid_at, written by POST /v1/hazards/ingest in single-event mode (file_path + event{label, valid_at} + live_event_id + intensity_unit) and hidden from every probabilistic reader api/v1/hazards.py:440-560, db/repositories/hazard_repository.py:575-578,626 The feed tick builds the same params and dispatches the same ingest_hazard chord — no second ingest path (D4)
The multipart upload path (POST /v1/hazards/upload) streams to disk with cap + quota, but its single_event declaration is HDF5-only — a GeoTIFF declared single-event is refused by assert_geotiff_upload_declaration, valid_at is decoded from the .h5 file, and the route has no valid_at, intensity_unit or live_event_id field api/v1/hazards.py:779-843, 869-883; core/hazard_upload.py:166-196 The pushed-observation seam is a new route that reuses the staging/quota helpers and then hands the staged path to the mode-3 seam POST /v1/hazards/ingest already builds — not "the upload path plus one field" (D3, W1.5; corrected by the reassess)
The event lane computes one snapshot on demand: POST /v1/live-events/{id}/compute → compute_event_impact → event_impact_results; unit weights, no ead/aai (ADR-078) api/v1/live_events.py::compute_live_event_impact_endpoint (:212), worker/tasks.py::compute_event_impact (:1467) Auto-compute is a hook that calls what the endpoint calls (D5); the lane itself is done
The ingest chord's commit callback already enqueues follow-up work (build_dataset_cog) for the committed dataset worker/ingest/aggregate_and_commit.py:1936-1946 The one place a "snapshot landed → score it" hook belongs
The worker already runs a beat-driven external poll (weekly poll_dataset_sources), a scoped "poll now" task (#905), and a worker-side download→Job+IngestJob→dispatch handoff (ingest_approved_dataset) worker/celery_app.py:150-158, worker/dataset_polling.py, worker/dataset_ingest.py:1-120 The tick is cadence + routing on a proven pattern, not new machinery (charter D4)
One compute queue, one worker, --concurrency=1 on prod; every task routed to compute; no Redis lock/singleton anywhere in src/ (threading.Lock only) worker/celery_app.py:64-93, docker-compose.hetzner.yml:41-52 A live queue + live-worker + a SET NX EX lock (D13); ingest chords stay on compute
Compose profiles never deploy (deploy-prod.yml passes --profile climada explicitly and nothing else); release.yml builds api + worker images only .github/workflows/deploy-prod.yml:462-523, release.yml:78,143 live-worker is an un-profiled service on the worker image — no new image, no workflow change
LIVE_MODE_ENABLED defaults false, is wired on api + worker (${LIVE_MODE_ENABLED:-false}), is true on prod since 2026-09-02 (container env, not .env.example.prod) config.py:736, docker-compose.yml:352, .env.example.prod:163-167 The flag gates route, queue and writer (guardrail 15); prod is live-on
The SSRF allowlist already carries earthquake.usgs.gov and firms.modaps.eosdis.nasa.gov; FIRMS_MAP_KEY is env-only, read by the pack, not by Settings config.py:232-241, .env.example:219-226 Promote the key to Settings.firms_map_key (secret); the USGS feed needs no key
The FIRMS rasteriser (CSV → 1 km max-BT GeoTIFF) and the ShakeMap grid.xml parser live in scripts/packs/ — outside both CI gates and outside the worker image scripts/packs/firms_bt_event.py:939-1190, scripts/packs/shakemap_event.py:755-1125 Move them under core/ingest/ with thin CLI shims left behind (W0.5)
GET /v1/info serves version, environment, git_sha only, and tests/test_api/test_info.py:26 pins that exact key set; the UI has no feature-flag layer, reads /v1/info nowhere, and a nav entry cannot be mounted on "a non-empty list" main.py::info (:325-333, _CODE_TO_HTTP_STATUS at :345); tests/test_api/test_info.py:26; ui src/App.tsx::NAV_ITEMS (:37-45, 8 entries; nested event route :183) features: {live_mode} on /v1/info (D8), with the exact-set test extended — never relaxed to a subset — so nothing else leaks onto the deploy-identity endpoint
The worker has no reusable Redis client (the only redis import is the API readiness probe, main.py:267); a beat task that opens SyncSessionLocal() without the org GUC reads zero rows silently under FORCE RLS; the cross-org beat precedent is worker/job_watchdog.py::reap_stale_jobs (for org_id in list_org_ids_sync(), :221), not dataset_polling.py, which iterates a code registry, and not surface_retention.py, which has no org loop db/rls.py::set_org_guc_sync (:102), ::commit_preserving_org_scope_sync (:134), ::list_org_ids_sync (:160); tests/test_worker/test_rls_lanes.py (the DB-free lane-selection test file the tick's test extends) The tick is an org loop with set_org_guc_sync per org (D4); W1.1 creates the worker's Redis client
Every completed compute writes a usage_events row (worker/tasks.py::_record_compute_usage, :89, called from compute_event_impact at :1775) that feeds usage_by_period.run_count; GET /v1/jobs (api/v1/jobs.py::list_jobs, :8-45) filters on status and job_type only and is the read behind Home's Recent runs and the Runs hub worker/tasks.py:89, 1775; migrations/versions/0039_*; api/v1/jobs.py::list_jobs A 5-minute feed would book ~288 metered runs and ~576 job rows per day per watch — auto-computes are unmetered and feed jobs carry an origin marker the default job list excludes (D5)
Two staging paths, two cleanup owners (the creator reclaims, core/hazard_upload_reclaim.py, #1263): an api-minted leaf under hazard_upload_dir is root-owned and reclaimed by the api-side sweep (reclaim_staged_hazard_uploads, started from main.lifespan) on the ingest job's terminal state — the sweep finds the owning job through Job.params["file_path"] (db/repositories/job_repository.py::staged_upload_jobs, :191-229; IngestJobRepository.set_staged_source is the worker's MinIO-key record and plays no part in reclaim); a worker-created dir is removed by stage_source's cleanup_local_dir param (worker/dataset_ingest.py:528) — never the other way round (uid mismatch) core/hazard_upload_reclaim.py:1-40, 265-437; db/repositories/job_repository.py::staged_upload_jobs; worker/ingest/stage_source.py::_cleanup_local_dir W1.5 stages like upload_hazard and dispatches with params["file_path"] set, which is the link the sweep reads; the tick stages per observation under dataset_source_download_dir/live/… and passes cleanup_local_dir (D4)
A validate_source failure leaves the ingest job in running until the 6 h wedged-ingest watchdog (#1271, on the Phase 14 milestone) issue #1271 A feed that ingests a bad raster would show a healthy last_polled_at and no last_error for 6 h — W1.1 depends on #1271 landing first
deploy-prod.yml runs up -d --remove-orphans before alembic upgrade head (it waits for the new api container, then migrates); its comment counts "all seven services" and the UI-deploy proof compares before_api/before_worker container ids only .github/workflows/deploy-prod.yml:509-546, 518, 584-585 On the 0092 release live-worker and beat start against a schema without live_feeds; the tick must tolerate that and the release checklist restarts them after the migration (D13)
hazard_ingest_min_valid_cells defaults to 0, so an all-nodata raster commits as a dataset core/ingest/coverage.py:32, 90 A zero-detection overpass must never become an observation (D3)
Provider adapters under ingest/sources/ call httpx directly; core/ssrf.py::SSRFPolicy is applied only by the admin download route and worker/download_task.py — download_platform_allowlist does not constrain in-process adapters core/ssrf.py, worker/download_task.py:67-69, ingest/sources/*.py Feed adapters are trusted in-process code (ADR-027) whose URLs are code-constructed from a fixed base; feed params never carry a host (guardrail 22)
The UI already has LiveEventMap (±2-snapshot warm window, stable per-snapshot source ids), SnapshotScrubber (play/step/now marker), EventHeader, EventLossHeadline, RankedAssetsTable, LayerPanel(rows: LayerRowDef[]), Splitter, useIntervalFetch, an events.spec.ts on a live-mode smoke stack, and a live i18n namespace ui src/components/live/*, src/map/styles/live.ts, src/hooks/useIntervalFetch.ts, tests/e2e/smoke/events.spec.ts, compose.yml:137,170 The Live tab is composition over these plus four new components (D9); no map-library change
liveEventChipKind reads status + source only; both prod replay watches are open with provider strings for source, so they render a green Near-real-time chip on a 2020 earthquake and a 2021 fire ui src/lib/liveEventChip.ts:23-26; prod events 2e3fd696…, da01cb7a… The watch carries an explicit kind (replay | near_real_time) from its feed (D6); the heuristic goes
The event result carries event_loss, assets[], total_assets, grade — no exposed count/value, no delta against the previous snapshot api/v1/live_events.py::get_live_event_result_endpoint (:357); schemas/live_event.py::LiveEventResultRead (:144) The charter's "two numbers" for wildfire and the delta-first panel need W1.6
The out-of-bounds tile defect (#1155) already shipped: PR #1160 (2026-09-03) answers with an empty PNG — not a 204, api/v1/tiles.py:172 records why — and increments tile_out_of_bounds; only the issue's manual prod box is open api/v1/tiles.py:41, 90, 172; PR #1160 No build item; the manual box rides the Phase 15 walk (W3.3) and closes #1155
The UI trunk gained the Home cards during planning (components/home/{StartHere,RecentRuns,Portfolios,Coverage,Notifications}Card.tsx on useCardFetch, one-shot by design) UI 32815e6 (#375) The Live-now card is one more home/*Card.tsx on useCardFetch, never on useIntervalFetch (W2.5)
Recorded, real, licensed fixtures already exist: the Greece-clipped ShakeMap (earthquake_event_pga.tif, 658 KB) and a real FIRMS Evia extract (firms_evia_2021_viirs_snpp_sp.csv, 11 KB); the six per-day Evia rasters (11.6 KB total) were written by the pack on 2026-09-03 and are on prod, not in the repo tests/fixtures/reference_datasets/; docs/data/volume-budget.md:128-129 The replay series fixture is a vendoring task, not a sourcing task (W3.1)
Phase 14 deferred to this phase: #1240 (sidecar RST misreport), and the charter stub in ui-revamp.md (visual revamp, measures, event sets, GeoTIFF exposure upload, per-asset impf_*, a third engine); #1155 (tile proxy 502 out of bounds) is open and unassigned phase-14-solidify.md "Out of scope"; issues #1240, #1155 #1240 and #1155 are admitted here (W0.6/W0.7); the rest is Phase 16 (D1)

Design record (full prose)

Decision Choice Why (evidence)
D1 Numbering Phase 15 is Live Risk. The visual revamp, adaptation measures and the other Phase 14 deferrals become the Phase 16 charter stub (ui-revamp.md header retargeted). The Live tab is built as the pilot of the revamp's recurring patterns (map-first, selection-driven inspector, layer cards, bottom drawer, scrubber), so Phase 16 inherits proven components rather than a mock-up owner instruction 2026-09-08 supersedes the 2026-09-05 "revamp is Phase 15" line in phase-14-solidify.md:16-18; nothing deferred is lost, only renumbered
D2 Sequencing Opens at Phase 14 close. The backbone W0 may be pulled forward beside Phase 14's remaining UI sessions (U5–U7) only after B11 lands — #1237 edits api/v1/hazards.py and docker/, both W0 hotspots — and only by owner instruction (Phase 13 ran beside Phase 11 on the same rule) phase-14-solidify.md "Backbone residual work order" B-2; hotspot table below
D3 Ingress = a feed contract, not a device integration A LiveFeed ABC under src/climate_lama/ingest/feeds/ (sibling of ingest/sources/, the ADR-027 extension-point style): poll(feed, *, now) -> list[Observation], Observation(valid_at, raster_path, intensity_unit, label, source_version, provenance); adapters constructed with an injectable httpx.Client. Three adapters: replay (an archived series from a manifest directory on the worker volume, released one observation per tick — the presentation driver and the reproducibility gate), firms_nrt (VIIRS S-NPP NRT area CSV over the watch AOI → 1 km max-brightness GeoTIFF in K), usgs_shakemap (FDSN event query over the AOI at M ≥ usgs_min_magnitude, producttype=shakemap; a new event or a new updateTime → grid.xml → PGA GeoTIFF in g; every revision is a new snapshot whose source names the version). Pushed observations — a drone orthomosaic, a partner detection product — enter through POST /v1/live-events/{id}/observations (multipart GeoTIFF + valid_at required + intensity_unit required + label): a new route that reuses upload_hazard's staging, cap and quota helpers and then builds the same mode-3 params POST /v1/hazards/ingest builds (hazards.py:501-560) — the path scripts/smoke_e2e.py:2340-2360 already proves for a GeoTIFF. Zero-detection contract: an adapter returns [] when a poll yields nothing above threshold; an empty or all-nodata raster is never an Observation, while last_polled_at is still stamped (poll health ≠ snapshot). Egress posture: adapters are trusted in-process code (ADR-027); every outbound URL is code-constructed from a fixed per-adapter base host, feed params never carry a host, and a test enforces it — download_platform_allowlist and SSRFPolicy do not apply to them and the plan says so the owner's brief names drones, satellites and "something else" that "lands in a database" — the common denominator is a time-stamped raster with a declared unit; ingest/sources/base.py:1-140 is the shape that already works for external providers; the multipart route's single_event is HDF5-only (core/hazard_upload.py:166-196), so the push seam cannot be that route plus a field (reassess correction A1)
D4 The tick Beat entry live-feed-tick every live_feed_tick_seconds (default 300) → worker/live_feeds.py::tick_live_feeds on the live queue. Tenancy first: the task is a list_org_ids_sync() loop — one org-scoped transaction per org (set_org_guc_sync, commit_preserving_org_scope_sync after each write), the shape worker/job_watchdog.py:219-233 already has — because an unscoped session under FORCE RLS reads zero live_feeds rows and looks healthy while never firing. Inside an org: for every enabled feed on an open watch whose cadence_seconds has elapsed, redis.set(f"live:tick:{feed_id}", nx=True, ex=2×cadence) on a worker-side client created for the purpose (redis.Redis.from_url); poll(now); stage each raster in its own dir under dataset_source_download_dir/live/{feed_id}/ and pass that dir as cleanup_local_dir (worker-created, worker-owned — the dataset_ingest.py:528 precedent; the api-side reclaim sweep never touches it); create jobs (origin="feed") + ingest_jobs rows through the helper lifted from dataset_ingest.py::_create_ingest_jobs and apply_async ingest_hazard in single-event mode with the same params POST /v1/hazards/ingest builds (hazards.py:524-534) on the compute queue; advance the feed cursor; stamp last_polled_at / last_error. Per-feed isolation (one bad feed never aborts the rest); the task no-ops with a log line when the flag is off and when the schema is behind (ProgrammingError on a missing relation — the deploy window in D13); the task is a thin sync entry over core/live_feeds.py, and the API's poll-now endpoint calls the same service function charter §12 recommendation on record; worker/dataset_ingest.py:470-560, 752 is the Job→IngestJob→dispatch precedent; dataset_polling.py is not the tenancy precedent (it iterates a code registry, unscoped); ingest chords stay on compute (charter D4, accepted contention)
D5 Auto-compute live_events.auto_compute (default true on a watch with a feed). Hook at the commit callback (aggregate_and_commit.py:1904-1946, _schedule_cog_build, called once at :2314): core/live_feeds.on_snapshot_committed(dataset) → if the dataset has a live_event_id and the watch has auto_compute → create a Job(IMPACT_CALC, origin="feed") and dispatch compute_event_impact with the watch's selector (portfolio_id | exposure_dataset_id), user_id = created_by_user_id (kept, for attribution), notify=false (no job_complete bell per tick — the Live surface is the notification; today the emit at worker/tasks.py:1786-1813 is unconditional) and metered=false (_record_compute_usage is skipped — a 5-minute feed would otherwise book ~288 runs a day into the org's usage_by_period; billing is parked with the End-State Vision and revisits this). Feed jobs never reach the human job lists: jobs.origin (user | feed) is set on both the ingest and the compute job, and GET /v1/jobs excludes feed unless ?include_automated=true — Home's Recent runs and the Runs hub are untouched. Needs a sync sibling of core/job_dispatch.create_and_dispatch_job (async-only today, core/job_dispatch.py:75-192) the UI's compute button is the only trigger today (LiveEventPage.tsx:307-319); a feed that ingests but never scores is not live risk; api/v1/jobs.py:62-100 has no automated filter
D6 Watch + feed model (migration 0092, lands alone) live_events += aoi (PostGIS Polygon 4326, nullable; defaulted from the bound book's member extent + live_aoi_buffer_km at watch creation), exposure_dataset_id (FK, nullable; XOR with portfolio_id enforced in core, matching the compute selector), auto_compute (bool), created_by_user_id (FK, nullable), kind (replay | near_real_time — one writer: attaching a feed sets it from the feed's kind, a PATCH may override, the API create defaults to near_real_time, the DB default replay only backfills existing rows). hazard_datasets += live_feed_id (which feed produced a snapshot; NULL when pushed), and every snapshot count is derived from it rather than kept in a counter. New table live_feeds(id, org_id, live_event_id FK CASCADE, kind, params JSONB, cadence_seconds, enabled, paused_reason, cursor JSONB, last_polled_at, last_error, max_snapshots, created_at, updated_at) with FORCE RLS (0082 predicate) and a row in _ORPHANED_ORG_SCOPED_TABLES. event_impact_results += exposed_count, exposed_value (nullable; filled by W1.6). jobs += origin (user | feed, NOT NULL default user) for D5. Typed Alembic ops; migration_window downgrade test; migrations-gate.yml is a real check models/live_event.py has none of these; a chip derived from a provider string is the defect the prod replays show; models/job.py has no origin marker
D7 Read model additions GET /v1/live-events rows gain latest_valid_at, latest_event_loss, snapshot_count, feed{kind, enabled, last_polled_at, last_error} in one query (no N+1, portfolios/summary precedent). GET …/results/{job_id} gains exposed_count, exposed_value. New GET …/results/{job_id}/delta?against=<job_id> → {event_loss_delta, exposed_count_delta, assets:[{exposure_id, name, loss_before, loss_after, delta}]} sorted by |delta|, paged, computed from the two stored loss vectors (core/eai_alignment.load_loss_exp) on the same exposure set; E_RESULT_STALE on exposure-set drift, and a new stable code E_RESULT_VECTOR_ABSENT when either side's loss_exp_path is NULL (the column is nullable by contract, models/event_impact_result.py:98-104) — never a 500 the rail and the delta panel are the two things a decision-maker reads first (GDACS "bold what changed", charter §9); portfolios/summary (api/v1/portfolios.py:165-197) is the no-N+1 aggregate precedent
D8 Feature discovery GET /v1/info gains features: {live_mode, geocoding} — both bool, that exact key set (live_mode landed with #1280, geocoding with the #1322 follow-up #1336, whose value is geocoder_enabled AND geocoder_advertised, never the master switch alone — see "Inputs from Phase 14"). The UI mounts the Live nav entry only when live_mode is true; the /live route always exists and renders an "off on this deployment" state otherwise. Replaces the Phase 13 dark-landing trick for a surface that has a nav entry. The exact-key-set assertions in tests/test_api/test_info.py (INFO_KEYS, FEATURE_KEYS) are extended by each arrival plus a flag-on/flag-off case — kept exact, never relaxed to a subset, because it is what stops other fields leaking onto a public deploy-identity endpoint main.py:310-326; a nav link cannot be gated on an empty list, and a UI env var would drift from the backbone flag; the SDK has no /v1/info method and scripts/verify-deploy.sh:181-201 reads only git_sha, so nothing else moves
D9 UI anatomy /live = watch-list rail (inline-start: one card per open watch — name, hazard chip, kind chip, freshness dot whose opacity encodes age, latest loss, a loss sparkline over its snapshots; sorted by latest observation) · map + scrubber (centre; the scrubber gains a Follow latest toggle that pins the view to the newest snapshot as it arrives) · inspector (inline-end, selection-driven and collapsible: header, the two numbers — exposed count/value and event loss with the screening badge — the delta panel, the feed status strip with source, cadence, last/next poll, latency budget and last error, "why this number", the life-safety line) · a bottom drawer with the ranked-assets table (sortable, grouped by layer). Layer cards: observed raster, event loss, AOI outline, admin boundaries. /live/:eventId is the deep link; /portfolios/:id/events/:eventId redirects to it with ?portfolio= (Phase 13 evidence URLs keep working; the Events section on the portfolio page stays and links there). A Create watch dialog (name, hazard, book or exposure dataset, AOI = the book's extent or the current map view, feed kind + params, cadence, auto-compute) and Close close the "creating a watch is API-only" gap. Home gains a Live now card. Nav = 9 charter §8 anatomy; ui-revamp.md patterns 1, 2, 8, 10, 12; every named component exists on UI trunk; "use the current map view as AOI" avoids a draw library
D10 Presentation mode = the replay feed, on real data The walk runs a replay feed over the North Evia 2021 series (real FIRMS archive extract, licence row MEASURED; the vendored fixture holds one observation, 2021-08-06, so the walk shows ≥1 snapshot from it — three once #1297's second series is vendored) at an accelerated cadence (e.g. one observation per minute). valid_at is the real observation instant — never re-stamped — and the watch's kind is replay, so the chip and the age line stay honest. A second real series (Rhodes 2023-07 or Attica/Varnavas 2024-08 from the FIRMS archive) is sourced under guardrail 10 for variety ADR-077 forbids demo data; a replay of real provider data through the real pipeline is real data — the theatre would be fake timestamps, which guardrail 17 refuses
D11 Feeds are per watch; nothing opens a watch by itself A feed belongs to one watch a user opened with an AOI and a selector. A detection stream that opens watches on its own (who owns it, which book, which AOI) is the R3 forward-risk rung and stays parked with that reason ownership and attribution of an auto-created watch are unresolved; RLS has no cross-org lane (charter §2)
D12 Flood The lane is peril-agnostic: a depth GeoTIFF in m on a river_flood watch computes against the seeded JRC/Huizinga curves through the observation-upload path today. No flood feed adapter in Phase 15: EFAS/GloFAS publish discharge and exceedance probability, Copernicus EMS and GFM publish extent — none publishes depth. W3.2 is a time-boxed sourcing spike (a real depth footprint from a primary provider, never from risk-assessment-tool); if none exists the flood feed is parked to the R3 phase with the finding recorded charter §6 (ERA5T dropped, EFAS is the recorded first rung this track overrode); 05-rat-convergence.md rules 1–2
D13 Deploy live-worker service, un-profiled, in docker-compose.yml, docker-compose.prod.yml and docker-compose.hetzner.yml: the worker image, celery … worker --queues=live --concurrency=1, the worker's healthcheck and volumes; REDIS_EXPORTER_CHECK_SINGLE_KEYS gains live. deploy-prod.yml's up -d --remove-orphans (:523) creates it with no workflow change and the explicit pull api worker (:500) already fetches its image; release.yml is untouched. Ordering caveat the workflow imposes: up -d runs before alembic upgrade head (the migration runs inside the new api container after a health wait, :529-546), so on the 0092 release live-worker and beat come up against a schema with no live_feeds and the first tick can fire in that window — the tick no-ops on a missing relation (D4), and W4.2's release checklist restarts live-worker and beat after the migration and asserts alembic_version = the release head (0093 since #1388 widened the attribution columns) before the first tick. The workflow's "all seven services" comment (:518) becomes eight and the UI-deploy proof (:584-585) gains before_live_worker. LIVE_MODE_ENABLED is set on api, worker, live-worker and beat (today wired on api and worker only); the tick no-ops when it is off. RAM: a second Celery process on the CX43 (16 GB) beside the sidecar — [ASSUMPTION ~300 MiB, measure at W0.3] charter §2 "profiles never deploy"; deploy-prod.yml:457-546
D14 Retention No dataset-level retention exists and none is added. A feed carries max_snapshots (default live_feed_max_snapshots = 500); reaching it pauses the feed with a last_error — nothing is deleted. Closing a watch purges nothing. Surface retention is untouched (snapshots build no surfaces) charter D5/§10 pin rule, scoped to what actually exists; a FIRMS day is ~2 KB
D15 Notifications and metering No new NotificationType (ADR-037 stays at six, models/enums.py:360-380). Auto-compute jobs suppress job_complete and write no usage_events row (D5); feed-ingested snapshots keep their file_size_bytes storage accounting (real bytes in the bucket) but, like every POST /v1/hazards/ingest body today (hazards.py:485-488), are not charged against the upload quota — feed volume is bounded by max_snapshots instead (D14). A feed error is visible on the feed status strip and the Home card. Guardrail 9 unchanged one bell entry per 5-minute tick would bury every other notification; one usage row per tick would make the org's monthly run_count meaningless
D16 Science posture Unchanged from the charter §7: wildfire = two numbers (exposed count/value, assumption-free; € loss screening-grade on the 1 km Lüthi curve), earthquake on ESRM20 PGA curves, lane grade always screening (ADR-078), no MMI. Copy says near-real-time and every feed states its latency budget: FIRMS NRT 1–3 h for Greece (charter §6; URT is US/Canada only); USGS ShakeMap products are versioned and revised for hours–days (charter §6). Initial availability of a ShakeMap for a non-US event is [UNVERIFIED] — the usgs_shakemap adapter records the observed origin-time → product updateTime delta for every Greek event it sees, and the feed strip shows "revised for hours–days" until a measured figure exists; "minutes to hours" is never shipped as copy without one guardrail 9/16; reassess correction B11
D17 Verification seams Injected now on the tick and the service; adapters take an httpx.Client (MockTransport, as DatasetSource tests do); the replay adapter is the offline fixture path. scripts/smoke_e2e.py gains a series leg: open a watch with a replay feed over the vendored Evia series, POST …/feeds/{id}/poll three times, assert three snapshots, three auto-computed results, exposed_count > 0, a delta between consecutive results, no ead/aai, and that a second identical run reproduces the last snapshot's ranked list (the charter §11 exit, deferred from Phase 13). UI: live.spec.ts on the smoke stack + a contract-map.ts entry per new client method (ui#315 turns the spec red otherwise) Phase 13 exit criterion 1 covered one snapshot on purpose; R2's gate is a series
D18 Showcase A fourth moment, Moment 4 — Decision-maker: "What is at risk right now?", with target: P15 steps; Goal C's enumeration in plan.md and CLAUDE.md gains it. The responder is a lens of the P3 persona, not a new one charter §13 parked it "if a real official asks — requires a one-line edit to Goal C"; the owner's instruction is that edit

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

Backbone: B1 W0.1 alone → W0.2 ∥ W0.4 ∥ W0.5 · B2 W0.3 ∥ #1240 · B3 W1.1a → W1.1b → W1.2 · B4 W1.3 ∥ W1.4 → W1.6 → W1.5 · B5 W1.7a → W1.7b → W3.1a → W1.8 · B6 W4.1 (after every build issue) · OPS W3.1b, W3.2, W3.3, W4.2 (owner). UI (after B5's contracts): U1 W2.0 → W2.1 ∥ W2.5 · U2 W2.2 → W2.3 → W2.4 · U3 W2.6. Eighteen backbone build cards (W0.1–W0.5, #1240, W1.1a–W1.8 as eleven, W3.1a, W4.1) + seven UI (the split cards below are the authoritative list); at the measured ≈100 min per serial Opus·xhigh issue, eight sessions of 3–5 issues, ≈5 orchestration days. Builders Opus·xhigh (seam-path floor applies to W0.3 and W1.8); no Fable in batch; orchestrators Opus·xhigh; combined live builders ≤3; every backbone session runs the repo-wide guard tests; migrations-gate.yml (W0.1) and sdk-regenerate.yml (W1.7) are real checks; the UI e2e-smoke.yml sibling gate is dispatched after W1.8 and after every UI session.

Landscape refresh (2026-09-08, web scan — what changed since the charter's §9)

  • Detection is now commoditised in Greece specifically. The national OroraTech constellation (four thermal satellites, a Greek ground station, minutes-level latency, 4 m hotspots) delivers straight to the Hellenic Fire Service; Google's FireSat launched its first three operational satellites in July 2026 (at least twice-daily now, heading for ≤20-minute refresh). Neither publishes a public API this scan could find. Consequence: unchanged from the charter — compete downstream of detection — and it is why the pushed observation seam (W1.5) matters: a partner detection product lands as a raster without this platform building the sensor side.
  • COP interoperability is the recurring gap (Draper's seven wildfire intelligence gaps; TAK-based common operating pictures; Picogrid's multi-sensor fusion; Esri's Imagery for Action). The patterns worth keeping: one feed, every item pinned to a map object (Watch Duty); age in the symbol (EFFIS recency palette → opacity here); what changed before what is (GDACS); assets by lifeline, then ranked (Esri EMO); human confirmation visually distinct from raw detection (Pano AI) — which here is the screening badge and the two-numbers rule.
  • Drone products arrive as damage-graded rasters within minutes (Texas A&M's CLARKE). That is R4's real shape and confirms the seam: a time-stamped GeoTIFF with a declared unit, never a video stream.
  • FIRMS facts re-checked: NRT within ~3 h globally (30 min US/Canada only), VIIRS 375 m, area API keyed by MAP_KEY with the 5000 transactions / 10 min cap; EFFIS redistributes FIRMS (no licence cure). The charter's numbers stand.

Sources: Draper — seven wildfire intelligence gaps · MDPI — UAV–satellite hybrid pipeline · Esri — FireGuard · Picogrid — wildfire intelligence · Earth Fire Alliance — FireSat · Esri — Imagery for Action · OroraTech — Greece national system · Google Research — FireSat · CTIF — Greek satellites · Texas A&M — CLARKE · NASA FIRMS · EFFIS — active fire detection.

Inputs from Phase 14 and their disposition

Item Disposition
#1240 sidecar RST misreport (deferred to Phase 15 by the residual triage) admitted, W0.7
#1155 tile proxy 502 out of bounds (Phase 13 mid-review F-2) already fixed by PR #1160 (2026-09-03, empty PNG); the open manual box rides W3.3 and closes the issue on the walk — no build item
#1271 validate_source failure leaves the ingest job running for 6 h (Phase 14 milestone, found by B-3) precondition, not admitted: W1.1 lists it as a fully qualified dependency; feed health is meaningless until a bad raster fails fast
ui#349 AssetDetailPanel dead tabs Phase 16 — portfolio surface, not live
Visual revamp, measures + climate-adjusted future hazard, stochastic event sets, GeoTIFF exposure upload, per-asset impf_*, a third engine, physical DROP of portfolio_assets (phase-14-solidify.md "Out of scope") Phase 16 charter stub (D1)
LIVE_MODE_ENABLED=false in .env.example.prod vs true on prod (Phase 14 reassess "not an item") W0.2 documents the prod value inline; the tick's no-op branch is the guard
#1329 BRAND_DEFAULT_* absent from both env templates (F-11 residue) admitted onto the milestone (2026-09-10), S / Sonnet — filler for any backbone session; tests/test_env_example.py is the check
ui#421 eight builder-proved findings from wave U9 — item 1 is the hand-copied maplibre mock whose getCanvas() returns a fresh object, so every cursor assertion in ten test files is vacuous admitted onto UI milestone 6 (2026-09-10) as a precondition of W2: live.spec.ts and the event view's map handlers must not inherit a vacuous mock; items 2–8 ride it as one cleanup batch
#1314 docs.yml green against a 404 · #1315 deploy-prod.yml cannot prove the UI landed (Phase 14 closeout findings) already on the milestone (type:ci); #1315 is a precondition of W4.2's coordinated-release proof
#1267 (docs gate / scripts/ lint / #226 perf AC) · #1303 (tests/test_worker/ deterministic-order hang) · #1240 (sidecar RST) already on the milestone from the Phase 14 residual triage; #1240 = W0.7
Address search on prod — #1322 decided 2026-09-10: pin-drop-only, reaffirming #534 (fast /reassess, 2 agents: the premises hold, deploy-prod.yml never activates the geocoding profile, so "enable" is a host action plus a three-site workflow change) the residual gap is discoverability, not capability. Backbone follow-up #1336 (Deps: #1280): an advertising-only geocoder_advertised setting, features.geocoding = geocoder_enabled and geocoder_advertised beside D8's live_mode — not geocoder_enabled alone, which docker-compose.prod.yml:86 forces true for the 503's diagnostic reason; D8's exact key set becomes {live_mode, geocoding} and docs/DEPLOYMENT.md "Geocoding in production" is amended. UI follow-up ui#424 (Deps: #378 + #1336): gate the address box's mount on the flag, keep ui#410's degrade path and its test, and make contract-map.ts's geocode entry assert the documented 503 + reason=connection instead of tolerating any 502–504

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

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

  1. Numbering / precedence (D1) — Live Risk takes 15; the revamp stub moves to 16 and the Live tab pilots its patterns.
  2. The feed abstraction (D3) and the choice of exactly three adapters plus a push seam; no sensor/point tables.
  3. Auto-compute at the commit callback with notify=false (D5, D15) rather than a new notification type or a user-facing toggle per job.
  4. AOI on the watch as a Polygon with a book-derived default (D6), and "current map view" instead of a draw tool in the UI.
  5. Replay-on-real-data as the presentation mode (D10) with original valid_at — the honest choice over accelerated fake timestamps.
  6. No flood feed adapter (D12); the peril rides the generic ingress and a sourcing spike decides.
  7. live-worker as an un-profiled compose service on the worker image (D13) instead of a second beat or an in-compute-queue tick.
  8. A fourth showcase moment (D18) and Goal C's enumeration edited.
  9. features on /v1/info (D8) as the UI's feature-flag source.
  10. The nav grows to nine (Phase 14 D13's "= 8" was that phase's exit criterion, not a standing rule).

Reassess record (2026-09-08, fast, 3 agents — two refuters split backbone/UI, one completeness critic; ≤20 read-only calls each)

27 claims: 24 confirmed, 3 corrected, 0 dropped; 12 items added. Corrected: the push seam is a new route reusing the upload helpers and the mode-3 seam, not "the upload path plus a field" (single_event is HDF5-only on the multipart route and it has no valid_at/intensity_unit); tests/test_api/test_info.py:26 pins /v1/info's exact key set and is extended, not relaxed; the ShakeMap latency copy was unsourced and now quotes the charter with initial availability marked unverified. Added: the tick's tenancy lane (list_org_ids_sync + per-org GUC — the cited dataset_polling.py precedent iterates a code registry, unscoped); auto-computes are metered unless suppressed (metered=false); feed jobs would drown GET /v1/jobs (jobs.origin + default exclusion); deploy-prod.yml migrates after up -d (tick tolerates a missing relation; release checklist restarts live-worker/beat after the migration; the "seven services" comment and the container-id proof are updated); an all-nodata overpass would commit (zero-detection → []); the delta endpoint on a NULL loss_exp_path (E_RESULT_VECTOR_ABSENT); _silence_transport_url_logging moves with W0.5; feed egress posture stated (guardrail 22); the FDSN re-derivation is W1.4's first task; phase-14-solidify.md's intro and showcase-script.md's opening line carry the renumbering. Rejected by the lenses and left out on purpose: a UI env var or build flag for the nav gate; a draw library; a charting dependency; a Prometheus feed metric (the per-feed last_polled_at/last_error + exit criterion 3 are the ops signal — an enhancement, not a loss); a region requirement on feed-built ingest bodies (assert_known_region accepts a blank region); the job watchdog (already job-type-agnostic and org-looped); user_id=None as a free bell suppression (it would also blank attribution). Unverified after the pass: Copernicus EMS rapid-mapping products (the GFM and GloFAS halves of D12 were grounded; W3.2's spike covers EMS); the UI e2e-smoke.yml dispatch line in the orchestration sketch (not read inside the lens's budget).

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

/phase-split drafts one spec per item from this section: grounding, user_visible, acceptance[], out_of_scope[] (guard: / defer: with a pointer), deps, parallel, size, files (seam paths named), buildable (true | spike | bootstrap). Everything a decompose agent would otherwise have to decide is fixed here or in the design record; the split's job is to ground it against the code at split time and size it with /analyze. Milestone Phase 15 -- Live Risk in both repos, label milestone:phase-15 (neither exists yet — the split creates them, backbone first). Existing issues are reused, never duplicated: #1240 goes on the milestone as-is (its body is current), #1155 closes on the walk, #1271 is a cross-issue dependency, ui#349 stays Phase 16.

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

Migration 0092_live_feeds.py (typed ops; copies 0091's _enable_org_isolation / _ORG_ISOLATION_PREDICATE helpers; no new PostgreSQL ENUM types — every new categorical column is String + CHECK, mirrored by a Python StrEnum, because an ENUM-recreating migration is the

1128 OID-cache class):

  • live_events += aoi geometry(Polygon, 4326) NULL — declared on the model with spatial_index=False and the GiST index created explicitly by the migration (op.create_index(..., postgresql_using="gist")), the house pattern of models/hazard.py:121; exposure_dataset_id uuid NULL FK exposure_datasets ON DELETE SET NULL, auto_compute boolean NOT NULL DEFAULT false, created_by_user_id uuid NULL FK users ON DELETE SET NULL, kind varchar(16) NOT NULL DEFAULT 'replay' CHECK (kind IN ('replay','near_real_time')) — the DB default exists only to backfill the existing rows (both prod watches read Replay the moment the migration runs); the API always writes kind explicitly (see the API contract).
  • hazard_datasets += live_feed_id uuid NULL FK live_feeds ON DELETE SET NULL (indexed) — which feed produced a snapshot; stamped by the ingest commit from params["live_feed_id"] beside live_event_id; NULL for a pushed observation and for every Phase 13 snapshot.
  • live_feeds: id uuid PK, org_id uuid NOT NULL FK organizations RESTRICT (indexed), live_event_id uuid NOT NULL FK live_events ON DELETE CASCADE (indexed), kind varchar(32) NOT NULL CHECK (kind IN ('replay','firms_nrt','usgs_shakemap')), params jsonb NOT NULL DEFAULT '{}', cadence_seconds int NOT NULL CHECK (cadence_seconds >= 60), enabled boolean NOT NULL DEFAULT true, paused_reason text NULL, cursor jsonb NOT NULL DEFAULT '{}', last_polled_at timestamptz NULL, last_error text NULL, max_snapshots int NOT NULL DEFAULT 500, created_at, updated_at; UNIQUE (live_event_id, kind); FORCE RLS org_isolation; listed in _ORPHANED_ORG_SCOPED_TABLES. No snapshot_count column: every count is derived — feed-level COUNT(hazard_datasets WHERE live_feed_id = …) (the max_snapshots check at poll time), watch-level COUNT(… WHERE live_event_id = …) (the list read) — so there is no counter for a writer to forget.
  • event_impact_results += exposed_count int NULL, exposed_value double precision NULL (the names mirror the portfolio plane's core/portfolio_service.py:336, 486 / core/rollup_service.py:466, so the two mean the same thing).
  • jobs += origin varchar(8) NOT NULL DEFAULT 'user' CHECK (origin IN ('user','feed')), index (org_id, origin, created_at) (no collision — idx_jobs_org_created is the only composite today). Deliberate deviation inside jobs: its status and job_type are pg ENUMs (models/job.py:29, 35); origin is String + CheckConstraint on purpose (the models/risk_surface.py:157 / SurfaceStatus precedent) — a builder must not "match the file".
  • models/enums.py += LiveEventKind, LiveFeedKind, JobOrigin (StrEnums); models/live_feed.py new; downgrade() reverses all five tables and is exercised through migration_window("0091").

Python contract (src/climate_lama/ingest/feeds/base.py):

@dataclass(frozen=True)
class Observation:
    valid_at: datetime          # tz-aware; the provider's observation instant, never "now"
    raster_path: Path           # GeoTIFF the adapter wrote under FeedState.staging_dir
    intensity_unit: str         # must be in seeded_units_for(haz_type)
    label: str                  # "VIIRS_SNPP_NRT 2026-09-08T01:12Z" / "us7000c7y0 atlas v1"
    source: str                 # provider string incl. version → hazard_datasets.source
    provenance: dict[str, Any]  # request URL with secrets stripped, row counts, product ids

@dataclass(frozen=True)
class FeedState:
    feed_id: uuid.UUID; live_event_id: uuid.UUID; org_id: uuid.UUID
    kind: str; params: dict[str, Any]; cursor: dict[str, Any]
    haz_type: HazardType; aoi_bbox: tuple[float, float, float, float]  # minx, miny, maxx, maxy
    staging_dir: Path

class LiveFeed(ABC):
    kind: ClassVar[str]
    base_host: ClassVar[str]    # the ONLY host the adapter talks to (guardrail 22)
    def __init__(self, *, settings: Settings | None = None, client: httpx.Client | None = None): ...
    @abstractmethod
    def poll(self, state: FeedState, *, now: datetime) -> tuple[list[Observation], dict[str, Any]]:
        """Return (observations, new_cursor). [] means 'nothing observed' — never an empty raster."""
    def close(self) -> None: ...

ingest/feeds/registry.py::FEED_BY_KIND: dict[str, type[LiveFeed]]. Adapter params are validated by a per-kind pydantic model in schemas/live_feed.py with extra="forbid" — no host, url or base_url key exists on any of them:

kind params cursor
replay {"series_dir": str} — a directory holding manifest.json + rasters, under dataset_source_download_dir or the /data mount {"next_index": int}
firms_nrt {"source": "VIIRS_SNPP_NRT", "confidence_min": "nominal" \| "high"} {"last_acq": iso8601, "seen": [ "lat,lon,acq_time", … ]} (the last window's dedupe keys)
usgs_shakemap {"min_magnitude": float (defaultusgs_min_magnitude), "product_preference": ["atlas","us"]} {"events": {"<usgs event id>": "<updateTime>"}}

Replay manifest.json (fixture and prod series alike): {"series": "evia_2021", "haz_type": "wildfire", "intensity_unit": "K", "source": "NASA FIRMS VIIRS S-NPP 375 m active fire, VIIRS_SNPP_SP (Collection 2) — replay", "license": "<the volume-budget row's verbatim text>", "citation": "<NASA LANCE FIRMS acknowledgement>", "source_url": "https://firms.modaps.eosdis.nasa.gov/", "observations": [{"file": "2021-08-06.tif", "valid_at": "2021-08-06T00:12:00Z", "label": "VIIRS_SNPP_SP 2021-08-06"}]} — the vendored fixture's observations verbatim (one entry); the prod series lists six. Fixture path tests/fixtures/reference_datasets/live_series/evia_2021/ (one raster, 2021-08-06 — the vendored CSV's single acquisition day — regenerated deterministically from it by core/ingest/firms_points.py, manifest.json, README.md); sdk-smoke.yml stages it to data/live_series/evia_2021/ beside the three .tif copies it already makes (the Stage the reference hazard rasters step) and sets SDK_SMOKE_SERIES_DIR=/data/live_series/evia_2021; the UI smoke compose.yml already mounts the backbone's tests/fixtures read-only and copies fixtures in its seed service, so it needs one copy line and no workflow or compose.ci.yml edit. The series leg prints E2E_READY_SERIES_GRC live_event_id=… feed_id=… snapshots=N results=N elapsed=…s (N ≥ 1 from the vendored one-observation series; three once #1297's second series is vendored), and sdk-smoke.yml's ready-line guard (:260-271, today grepping ^E2E_READY_GRC, ^E2E_READY_CYP, ^E2E_READY_EVT_GRC) gains ^E2E_READY_SERIES_GRC — the workflow's own rule that an env-var-gated leg must fail red, not vanish, when its variable is unset. The smoke job's ceiling is timeout-minutes: 25 (:65); no measured duration is recorded anywhere, so W1.8 records the observed gh run view duration before and after the leg.

Worker names: module climate_lama.worker.live_feeds (added to celery_app.py's include=[…]); tasks climate_lama.worker.live_feeds.tick_live_feeds (beat entry live-feed-tick, crontab(minute=f"*/{max(1, settings.live_feed_tick_seconds // 60)}"), expires=live_feed_tick_seconds) and climate_lama.worker.live_feeds.poll_live_feed(feed_id, org_id) (the scoped task poll-now dispatches, the poll_dataset_source #905 shape); both routed to live; Redis key live:tick:{feed_id} (SET NX EX 2×cadence) on a redis.Redis.from_url(settings.celery_broker_url) client created in the module; staging dir f"{dataset_source_download_dir}/live/{feed_id}/{valid_at:%Y%m%dT%H%M%SZ}/", passed as cleanup_local_dir. The tick builds the ingest params exactly as api/v1/hazards.py::ingest_hazard does: {name: f"{watch.name} — {label}", haz_type, file_path, event: {label, valid_at}, live_event_id, intensity_unit, source, license, citation, source_url, region: None, source_format: "geotiff", cleanup_local_dir, live_feed_id, org_id, user_id: created_by_user_id} and creates the ingest_jobs + jobs(origin="feed") rows through a new row-creation seam only —

# src/climate_lama/core/ingest_dispatch.py
def create_ingest_jobs_sync(*, org_id: uuid.UUID, params: dict[str, Any],
                            origin: JobOrigin = JobOrigin.USER) -> tuple[str, str, dict[str, Any]]:
    """Create ingest_jobs + jobs rows under the org GUC (set_org_guc_sync, never a raw
    set_config); return (job_id, ingest_job_id, params) with org_id + ingest_job_id injected,
    so the caller passes exactly that dict to ingest_hazard.apply_async(queue=...)."""

worker/dataset_ingest.py::_create_ingest_jobs keeps its whole signature and every params-building line (name, _identity_params, _raster_params, cleanup_local_dir, pending_ingest_id, owner_user_id) and ends by delegating row creation to that helper — its callers and tests are untouched; the queue stays the caller's argument (compute for ingest chords). The commit path stamps hazard_datasets.live_feed_id from params["live_feed_id"] where it stamps live_event_id today (worker/tasks.py::ingest_hazard, :2257). compute_event_impact params += notify: bool = True, metered: bool = True; the auto-compute dispatch passes both False and origin="feed". The hook is core/live_feeds.on_snapshot_committed(*, org_id: uuid.UUID, dataset_id: uuid.UUID) -> bool — the shape of _schedule_cog_build, called beside it at aggregate_and_commit.py:2314 in sync scope after the async commit has closed its session (asyncio.run(_commit()), :2276); it opens its own SyncSessionLocal() under set_org_guc_sync, returns False for a dataset without a live_event_id or a watch without auto_compute, and otherwise creates the compute job and dispatches. poll_live_feed takes the same live:tick:{feed_id} lock (returns {"skipped": "locked"} when held), re-checks live_mode_enabled and feed.enabled, and advances the cursor in the same transaction as the dispatch, so a Poll-now during a tick never produces a second snapshot. A broker outage (redis.RedisError on the lock) degrades the tick to a logged skip — beat must never crash on it.

API contract (roles: VIEWER reads; ANALYST+ writes):

Route Body / params Answer
POST /v1/live-events += aoi: GeoJSON Polygon \| null (derived from the book's member extent + live_aoi_buffer_km when null and portfolio_id is set — computed at watch creation, stored, never recomputed), exposure_dataset_id, auto_compute (default false), kind (default near_real_time; always written explicitly by the API — the DB default is the migration backfill only) 201 read
GET /v1/live-events, GET …/{id} read += aoi, exposure_dataset_id, auto_compute, created_by_user_id, kind, latest_valid_at, latest_event_loss, snapshot_count (= COUNT(hazard_datasets WHERE live_event_id), so a pushed-observation watch counts too), feed: {id, kind, enabled, cadence_seconds, last_polled_at, last_error, paused_reason} \| null — one aggregate query, no N+1 200
GET …/{id}/snapshots rows += live_feed_id \| null ("arrived via feed X" vs pushed) 200
PATCH /v1/live-events/{id} any of name, auto_compute, aoi, kind 200 read
POST …/{id}/feeds {kind, params, cadence_seconds, max_snapshots?}; sets the watch's kind: replay when the feed kind is replay, else near_real_time (a later PATCH on the watch may override; the UI dialog never sends kind) 201 feed read {id, live_event_id, kind, params, cadence_seconds, enabled, paused_reason, cursor, last_polled_at, last_error, snapshot_count (derived), max_snapshots, created_at, updated_at}
GET …/{id}/feeds · PATCH …/feeds/{feed_id} (enabled, cadence_seconds, params, max_snapshots; re-enabling clears paused_reason) · DELETE …/feeds/{feed_id} 200 · 200 · 204
POST …/feeds/{feed_id}/poll rate-limited live_feed_poll_rate_limit 202 {"feed_id", "queued": true} (dispatches poll_live_feed; callers poll GET …/snapshots)
POST …/{id}/observations multipart file (GeoTIFF), valid_at (required), intensity_unit (required), label?, source? 202 {job_id, ingest_job_id}
GET …/results/{job_id} += exposed_count, exposed_value
GET …/results/{job_id}/delta?against=<job_id>&limit&offset {job_id, against_job_id, valid_at, against_valid_at, event_loss_delta, exposed_count_delta, exposed_value_delta, assets: [{exposure_id, name, lat, lon, loss_before, loss_after, delta}], total_assets} sorted by \|delta\| desc
GET /v1/jobs += include_automated: bool = false rows gain origin
GET /v1/info += features: {"live_mode": bool}

Codes (each = a Code member + _CATALOG entry in core/errors.py (tests/test_api/test_error_envelope.py::test_every_registered_code_has_catalogue_entry enforces the pair), an exception class with code: ClassVar[Code] in core/exceptions.py, a main.py::_CODE_TO_HTTP_STATUS entry (a code without one answers 400 — E_RESULT_STALE has none today and so answers 400, not the 409 its semantics imply; W1.6 adds E_RESULT_STALE: 409 beside its own), and the SDK mirror via sdk/python/scripts/regenerate_errors.py, gated by sdk-regenerate.yml): E_LIVE_FEED_NOT_FOUND 404 · E_LIVE_FEED_KIND_UNKNOWN 422 · E_LIVE_FEED_PAUSED 409 · E_LIVE_WATCH_SELECTOR_CONFLICT 422 · E_RESULT_VECTOR_ABSENT 409 (W1.6 owns this one; W1.7b the other four — the two land serially on errors.py).

Settings (config.py, env in upper case, both .env.example files, parity test green): live_feed_tick_seconds: int = 300 · live_feed_max_snapshots: int = 500 · live_aoi_buffer_km: float = 10.0 · live_feed_http_timeout_seconds: float = 30.0 · live_feed_poll_rate_limit: str = "10/minute" · firms_map_key: str = "" (secret; empty → the FIRMS adapter pauses the feed with paused_reason="FIRMS_MAP_KEY not configured") · usgs_min_magnitude: float = 4.0.

UI contract: routes /live (src/pages/LivePage.tsx) and /live/:eventId (LiveEventPage.tsx, recomposed); /portfolios/:id/events/:eventId redirects to /live/:eventId?portfolio=:id (a params-aware sibling of App.tsx::RedirectPreservingSearch); NAV_ITEMS += {to: '/live', key: 'nav.live', requiresFeature: 'live_mode'} with nav.live in common.json; AuthContext gains features: {live_mode: boolean} read once after login from the new api.getInfo() (InfoResponse type). New components under src/components/live/: WatchListRail, WatchCard, LossSparkline (recharts), Inspector, AssetsDrawer, FeedStatusStrip, DeltaPanel, CreateWatchDialog; Home card src/components/home/LiveNowCard.tsx on useCardFetch. i18n: live.json gains rail.*, inspector.*, feed.*, delta.*, create.*, drawer.*, off.*; home.json gains liveNow.*; the ns list in i18n/index.ts is unchanged (live is already registered). Client methods: getInfo, createLiveEvent, patchLiveEvent, closeLiveEvent, listFeeds, createFeed, patchFeed, deleteFeed, pollFeedNow, uploadObservation, getEventDelta — each with a contract-map.ts entry (COVERED where the smoke walk arms a resource; Resources gains liveFeedId: string | null and contract.spec.ts discovers it; SKIPPED with a reason otherwise) and fixtures in src/test/fixtures/liveEvents.ts captured from real payloads.

SDK: LiveEventsAPI.create / get / list / patch / close / list_feeds / create_feed / patch_feed / delete_feed / poll_feed / upload_observation / delta, mirrored in aio.py; version bump in W1.7b; sdk-smoke green.

ADR-081 (lands with W0.4, extended by W1.1b): live feeds are trusted in-process adapters with code-constructed URLs; the tick runs per org under the GUC; feed-driven work is attributable (created_by_user_id, origin="feed"), unnotified and unmetered; a replay is real archived data on a real clock.

Split cards (one per build issue; Files: seam paths marked (seam), shared files (hotspot))

Filed 2026-09-08 by /phase-split phase-15 (backbone milestone 15, UI milestone 6; label milestone:phase-15 in both; sizes from /analyze, no Fable candidates). Backbone: W0.1 #1279 · W0.2 #1280 · W0.3 #1281 · W0.4 #1282 · W0.5 #1283 · W1.1a #1285 · W1.1b-1 (lifted ingest row-creation seam, split out of W1.1b) #1286 · W1.1b #1291 · W1.2 #1292 · W1.3 #1288 · W1.4 #1289 · W1.5 #1294 · W1.6 #1293 · W1.7a #1287 · W1.7b #1295 · W1.8 #1296 · W3.1a #1290 · W4.1 #1298 ·

1240 reused · #1155 on the milestone, closes on the walk; owner-run trackers W3.1b #1297 · W3.3

1300 · W4.2 #1299; spike W3.2 #1284. UI: W2.0 #378 · W2.1 #379 · W2.2 #381 · W2.3 #382 · W2.4

383 · W2.5 #380 · W2.6 #384. Split decisions beyond the cards: each issue registers the error

codes it raises (W1.1a the three feed codes, W1.7a the selector code, W1.6 the vector code — W1.7b is consumer-only on errors.py); W0.2 carries all seven settings and W0.4 all three params models so downstream issues never co-edit config.py / schemas/live_feed.py; LiveFeed gains min_cadence_seconds and a FeedPaused exception so adapters can pause a feed; the dialog's auto_compute toggle defaults on. Trackers and the spike are never passed to /phase-build.

Id · title Grounding (checked where) User-visible Files Deps · Parallel · draft size · kind Required boxes Out of scope
W0.1 feat(db): migration 0092 — live_feeds, watch AOI/selector/kind, hazard_datasets.live_feed_id, jobs.origin, exposed columns MISSING — models/live_event.py has 9 columns; grep -rn live_feeds src tests → none; models/job.py no origin; models/hazard.py no live_feed_id; models/event_impact_result.py no exposed_*; last migration 0091 internal — none (prod watches flip to Replay) migrations/versions/0092_live_feeds.py (seam), src/climate_lama/models/live_event.py, models/live_feed.py, models/hazard.py, models/job.py, models/event_impact_result.py, models/enums.py (hotspot), tests/db/test_app_role_rls_isolation.py, tests/test_db/test_live_feeds_rls.py, tests/test_migration_0092_live_feeds.py none · no (lands alone) · M · build alembic upgrade head from scratch under migrations-gate.yml; downgrade through migration_window; RLS isolation test lists live_feeds; prove-it-fails: the isolation test goes red with the policy dropped; seam box: the migration is executed from scratch on real PostGIS by the gate and the run id recorded; aoi GiST via explicit op.create_index + spatial_index=False on the model guard: no repository or API code; guard: no pg ENUM types (origin is String+CHECK on purpose, unlike jobs.status); defer: AOI default derivation → W1.7a
W0.2 feat(core): live-feed settings + /v1/info features PARTIAL — config.py::live_mode_enabled exists; no live_feed_*/firms_map_key/usgs_min_magnitude; main.py::info has 3 keys; test_info.py:26 exact set; every app service already carries env_file, so a new key reaches prod without a compose edit GET /v1/info gains features src/climate_lama/config.py (hotspot), src/climate_lama/main.py, tests/test_api/test_info.py, .env.example (seam), .env.example.prod (seam), tests/test_env_template_parity.py, docs/CLIMATE_LAMA.md (hotspot, §6 "Live Event Mode") none · yes · S · build exact-set assertion extended + flag on/off cases (prove-it-fails: flip the flag and the features assertion goes red); parity test green; seam box: both .env.example* entries named with their defaults guard: no reader of the new settings beyond /v1/info; guard: firms_map_key never logged
W0.3 chore(ci): live queue + live-worker service in the three compose files + deploy proof MISSING — celery_app.py::task_routes routes all 25 tasks to compute; no live-worker in any compose file; docker-compose.hetzner.yml:44-49 pins the existing worker to --queues=compute and carries no env_file/environment at all (a pure command override on prod.yml); deploy-prod.yml:519 "seven services", :585 before_worker internal — none src/climate_lama/worker/celery_app.py (hotspot), docker-compose.yml (seam), docker-compose.prod.yml (seam), docker-compose.hetzner.yml (seam), .github/workflows/deploy-prod.yml (seam), docs/DEPLOYMENT.md, docs/RUNBOOK.md (hotspot) none · yes · M · build (Opus — seam floor) seam-resolution box; the existing worker keeps --queues=compute; the new live-worker declares its own env_file: .env.prod in prod.yml and its --queues=live --concurrency=1 command in the hetzner override; LIVE_MODE_ENABLED wired on beat + live-worker; REDIS_EXPORTER_CHECK_SINGLE_KEYS: "0=compute,1=live"; real-stack box → owner WSL walk, PR lands Refs guard: no task module (W1.1b); guard: no release.yml change; defer: RAM measurement → W3.3
W0.4 feat(ingest): LiveFeed contract, registry, replay adapter, ADR-081 MISSING — src/climate_lama/ingest/feeds/ absent; sibling shape ingest/sources/base.py::DatasetSource internal — none src/climate_lama/ingest/feeds/{__init__,base,registry,replay}.py, src/climate_lama/schemas/live_feed.py (params models), tests/test_ingest/test_feeds_base.py, tests/test_ingest/test_feed_replay.py, docs/DECISIONS.md (hotspot) none · yes · M · build replay adapter releases exactly one unreleased observation per poll on the manifest and returns [] at the end; injected now; a test that no adapter reads a host/url from params (guardrail 22); ADR-081 present guard: no worker task, no DB; guard: no FIRMS/USGS code
W0.5 refactor(ingest): FIRMS rasteriser + ShakeMap grid parser into core/ingest EXISTS in scripts/packs/ only (firms_bt_event.py::build_grid, rasterise_csv, rasterise_csv_files, write_geotiff, _silence_transport_url_logging; shakemap_event.py::parse_pga_grid, clip_window, write_geotiff), exercised by tests/scripts/test_firms_pack.py:91, 626, 662 and test_shakemap_pack.py:121, 340; docker/worker.Dockerfile never COPYs scripts/; [tool.mypy] files = ["src/climate_lama"]; direction precedent: scripts/packs/_wildfire_fwi_common.py:117 already imports core.ingest.netcdf internal — none src/climate_lama/core/ingest/firms_points.py, core/ingest/shakemap_grid.py, scripts/packs/firms_bt_event.py (seam), scripts/packs/shakemap_event.py (seam), tests/scripts/test_firms_pack.py, tests/scripts/test_shakemap_pack.py, tests/test_core/test_firms_points.py, tests/test_core/test_shakemap_grid.py none · yes · M · build byte-identical GeoTIFF on the vendored CSV and ShakeMap fixture before/after (prove-it-fails: flip one cell); rasterise_csv_files moves with rasterise_csv; mypy covers the moved code; seam box: both pack CLIs run end-to-end on the fixtures after the move with their output hashes recorded in the PR guard: no behaviour change; guard: no adapter code
#1240 (existing issue, as-is — the split adds the milestone, never recreates) see the issue internal per its ## Execution none · yes · S · build per the issue per the issue's own "Out of scope"
W1.1a feat(core): live_feeds service + repository (CRUD, due-feed selection, cursor, pause) MISSING — no core/live_feeds.py, no db/repositories/live_feed_repository.py internal — none src/climate_lama/core/live_feeds.py, src/climate_lama/db/repositories/live_feed_repository.py, tests/test_core/test_live_feeds.py, tests/test_db/test_live_feed_repository.py W0.1, W0.4 · no · M · build tick_due_feeds(now) selects only enabled feeds on open watches whose cadence elapsed (injected clock); max_snapshots pauses with paused_reason; DB test on the repository under the org GUC guard: no Celery task; guard: no HTTP
W1.1b feat(worker): live-feed tick + poll-now tasks, org loop, Redis lock, staging, lifted ingest dispatch MISSING — no worker/live_feeds.py; dataset_ingest.py::_create_ingest_jobs (:470) is module-private; core/job_dispatch.py async-only; no Redis client in the worker internal — none src/climate_lama/worker/live_feeds.py, src/climate_lama/worker/celery_app.py (hotspot: include + beat), src/climate_lama/core/ingest_dispatch.py, src/climate_lama/worker/dataset_ingest.py, src/climate_lama/core/job_dispatch.py, tests/test_worker/test_live_feeds_tick.py, tests/test_worker/test_rls_lanes.py, tests/db/test_live_feeds_tenancy.py, tests/test_worker/test_dataset_ingest.py W1.1a, W0.2, W0.3, #1271 · no · L (split-candidate if /analyze says so) · build org loop under set_org_guc_sync — DB test: two orgs' feeds each tick once and never see each other; lane test in test_rls_lanes.py (its _RecordingSyncSession template) covering both the tick and the lifted helper (which replaces _create_ingest_jobs's raw set_config text with set_org_guc_sync); the helper is the row-creation seam only — _create_ingest_jobs keeps its signature and delegates (its tests unchanged); params["live_feed_id"] passed and stamped at commit; flag off → no-op log; ProgrammingError on a missing relation → no-op log; lock held → skip; redis.RedisError → logged skip, never a crash; poll_live_feed takes the same lock, re-checks the flag and enabled, advances the cursor in the dispatch transaction (test: a poll-now during a tick makes no second snapshot); cleanup_local_dir passed; prove-it-fails: unset the GUC and the tick sees zero feeds guard: dataset_ingest behaviour unchanged (its tests green); guard: no auto-compute (W1.2); guard: no API
W1.2 feat(worker): auto-compute on snapshot commit — notify=false, metered=false, origin=feed MISSING — _schedule_cog_build (:1904, called at :2314 in sync scope after asyncio.run(_commit()) closed its session) is the only post-commit hook; compute_event_impact has no notify/metered params; JOB_COMPLETE emitted unconditionally at tasks.py:1795; _record_compute_usage called at :1775 internal — none (a snapshot scores itself) src/climate_lama/worker/ingest/aggregate_and_commit.py (hotspot), src/climate_lama/worker/tasks.py (hotspot), src/climate_lama/core/live_feeds.py, tests/worker/test_compute_event_impact.py, tests/test_worker/test_aggregate_commit_live_hook.py, tests/test_db/test_usage_events_feed_runs.py W1.1b · no · M · build on_snapshot_committed(*, org_id, dataset_id) -> bool opens its own org-scoped sync session (prove-it-fails: drop the GUC → the hook sees no watch and dispatches nothing); ingest one snapshot on an auto_compute watch → exactly one event_impact_results row; auto_compute=false → none; a feed at max_snapshots (derived count) is paused with paused_reason at poll time, not here; N ticks add 0 to usage_by_period.run_count; no JOB_COMPLETE row; prove-it-fails: metered=True and the count moves guard: compute_impact (annual lane) untouched; guard: no result-shape change (W1.6)
W1.3 feat(ingest): firms_nrt adapter MISSING — no adapter; reuse core/ingest/firms_points.py (W0.5) and re-derive the request planning against the adapter's own params, reading scripts/packs/firms_bt_event.py::AreaRequest / ::plan_requests (:450, :554) as a reference only, never importing from scripts/ (guardrail 13: scripts/ is not COPYd into docker/worker.Dockerfile and the adapter runs in the worker; #1334 item 3, same as W1.4) internal — none src/climate_lama/ingest/feeds/firms_nrt.py, src/climate_lama/ingest/feeds/registry.py (hotspot), tests/test_ingest/test_feed_firms_nrt.py, docs/data/volume-budget.md (hotspot) W0.4, W0.5, W0.2 · yes (∥ W1.4) · M · build MockTransport tests on the vendored CSV; zero rows → [], last_polled_at stamped; dedupe on (latitude, longitude, acq_time); cadence floor 300 s; key never in logs (prove-it-fails: a log-capture test); volume-budget row for the NRT product before any prod poll guard: no MODIS source; guard: no perimeter/spread logic; defer: FIRMS commercial licence position → charter §10
W1.4 feat(ingest): usgs_shakemap adapter (FDSN re-derivation first) MISSING — no adapter; reuse core/ingest/shakemap_grid.py (W0.5, #1283) for the grid half only and re-derive the request planning against UsgsShakeMapFeedParams (shipped by #1282), reading scripts/packs/shakemap_event.py::resolve_event_id / ::select_shakemap_product as a reference — never importing from scripts/ (guardrail 13; #1334 item 3); charter §6 marks the bbox query [UNVERIFIED] internal — none src/climate_lama/ingest/feeds/usgs_shakemap.py, ingest/feeds/registry.py (hotspot), tests/test_ingest/test_feed_usgs_shakemap.py, tests/fixtures/feeds/usgs/*.json W0.4, W0.5 · yes (∥ W1.3) · M · build first task: pin the FDSN query form by a recorded-response test (fallback documented); a newer updateTime → a new observation whose source names the version; records origin→updateTime delta in provenance; zero events → [] guard: no MMI; guard: PGA in g only; defer: raster.zip availability → recorded in the adapter's README
W1.5 feat(api): POST /v1/live-events/{id}/observations — pushed raster observations MISSING — upload_hazard has no valid_at/intensity_unit/live_event_id and refuses GeoTIFF single events (core/hazard_upload.py::assert_geotiff_upload_declaration, :196-200); helpers core/upload_streaming.py::staged_upload_path, stream_upload_to_path; the reclaim sweep finds a leaf through Job.params["file_path"] (job_repository.py::staged_upload_jobs), which upload_hazard sets before create_and_dispatch_job (hazards.py:1026 → 1151) analysts can push an observation to an open watch from any client src/climate_lama/api/v1/live_events.py (hotspot), src/climate_lama/core/live_events.py, src/climate_lama/schemas/live_event.py (hotspot), sdk/python/climate_lama_client/{client,aio}.py (hotspot), tests/test_api/test_live_events_observations.py, docs/CLIMATE_LAMA.md (hotspot) W0.1, W1.1a, W1.2 · no · M · build streams under hazard_upload_dir and dispatches through create_and_dispatch_job(job_type=DATA_INGEST) with params["file_path"] = str(target_path) — DB test: staged_upload_states([path]) returns a state for the observation job (prove-it-fails: write the path under another key and the sweep would reclaim a live leaf); refused on a closed watch / flag off / unit not seeded; jobs.origin="user"; auto-compute fires end-to-end without editing core/live_feeds.py; prove-it-fails: omit valid_at → 422 guard: no change to upload_hazard; guard: GeoTIFF only (no .h5); guard: no edit to core/live_feeds.py
W1.6 feat(api): exposed count/value on event results + delta endpoint + E_RESULT_VECTOR_ABSENT MISSING — LiveEventResultRead has no exposed_* (the names exist only on the portfolio plane, core/portfolio_service.py:336, 486); no delta route; loss_exp_path nullable; E_RESULT_STALE has no _CODE_TO_HTTP_STATUS entry (answers 400) the two numbers and "what changed" become readable src/climate_lama/worker/tasks.py (hotspot), src/climate_lama/api/v1/live_events.py (hotspot), src/climate_lama/schemas/live_event.py (hotspot), src/climate_lama/core/eai_alignment.py, src/climate_lama/core/errors.py (hotspot), src/climate_lama/core/exceptions.py, src/climate_lama/main.py, sdk/python/climate_lama_client/errors.py (hotspot), tests/worker/test_compute_event_impact.py, tests/test_api/test_live_events_delta.py W0.1, W1.2 · no · M · build exposed_* rule per peril documented and tested (prove-it-fails: an all-zero intensity fixture yields exposed_count == 0); delta on a hand-inserted NULL-vector row → E_RESULT_VECTOR_ABSENT 409, and E_RESULT_STALE gains its 409 entry (test both statuses); sdk-regenerate.yml real check; annual payloads carry neither key guard: no change to annual results; guard: no UI
W1.7a feat(api): watch create/patch additions, AOI default, list summary, jobs include_automated PARTIAL — LiveEventCreate has 5 fields (extra="forbid"); no PATCH route; list_jobs has no origin filter; the router registers 8 routes today watches can be created with an AOI and a selector; the list carries freshness and loss src/climate_lama/api/v1/live_events.py (hotspot), src/climate_lama/api/v1/jobs.py, src/climate_lama/core/live_events.py, src/climate_lama/db/repositories/live_event_repository.py, src/climate_lama/db/repositories/job_repository.py, src/climate_lama/schemas/live_event.py (hotspot), tests/test_api/test_live_events.py, tests/test_api/test_jobs_list_origin.py W0.1 · no · M · build AOI derived from the book's extent + buffer at creation when omitted (test with a fixture book; prove-it-fails: a book with no members → 422, never an empty polygon); kind always written (default near_real_time); list summary with derived snapshot_count in one query (test counts statements); ten feed jobs leave the default GET /v1/jobs page unchanged (prove-it-fails: include_automated=true shows them) guard: no feed routes (W1.7b); guard: selector XOR enforced in core, not the schema
W1.7b feat(api): feeds sub-resource, poll-now, feed codes, SDK MISSING — no feed routes; LiveEventsAPI has compute/results only analysts attach, pause and poll feeds from any client src/climate_lama/api/v1/live_events.py (hotspot), src/climate_lama/core/live_feeds.py, src/climate_lama/schemas/live_feed.py, src/climate_lama/core/errors.py (hotspot), src/climate_lama/core/exceptions.py, src/climate_lama/main.py, src/climate_lama/config.py (hotspot, rate limit), sdk/python/climate_lama_client/{client,aio,errors}.py (hotspot), tests/test_api/test_live_feeds_api.py, docs/CLIMATE_LAMA.md (hotspot) W1.1b, W1.6 (errors.py serial), W1.7a · no · M · build the four codes registered in all four places (prove-it-fails: the catalogue test goes red with an entry removed); creating a replay feed sets the watch's kind to replay, any other kind to near_real_time (test both); poll-now dispatches the scoped task and is rate-limited (test the 429); sdk-regenerate.yml real check; SDK version bump guard: no UI; defer: org-level feed subscriptions → charter R3
W1.8 test(ci): series smoke leg on the replay feed + fixture staging PARTIAL — smoke_e2e.py::_walk_event_leg (:2267) is single-snapshot; HazardLeg/CountryLeg are NamedTuples (:160, :242); sdk-smoke.yml stages three .tifs (:127-131) and its ready-line guard (:260-271) greps three prefixes; timeout-minutes: 25 (:65) internal — the phase's exit criterion 1 scripts/smoke_e2e.py (seam), .github/workflows/sdk-smoke.yml (seam), tests/scripts/test_smoke_e2e_series_leg.py, docs/RUNBOOK.md (hotspot) W3.1a, W1.7b, W1.2 · no · M · build (Opus — seam floor) unit test on a stubbed client: N polls → N snapshots → N results with N ≥ 1 from the vendored one-observation series (three once #1297's second series is vendored), exposed_count > 0, delta answers, no ead/aai, reproducible ranked list (prove-it-fails: point SDK_SMOKE_SERIES_DIR at an empty dir → red); the workflow's ready-line guard requires ^E2E_READY_SERIES_GRC (prove-it-fails: unset SDK_SMOKE_SERIES_DIR → the job goes red, not green); seam box: SDK_SMOKE_SERIES_DIR and the staged path resolved inside the smoke container and recorded; real-stack box → orchestrator: one sdk-smoke.yml dispatch on trunk after merge records the snapshot and result counts (≥1 from the vendored series; three once #1297's second series is vendored) and the observed gh run view duration against the 25-minute ceiling; the PR lands Refs and the issue closes on that run guard: the existing event leg's assertions unchanged
W3.1a test(fixtures): vendored Evia 2021 replay series MISSING — no live_series/ under the fixtures dir; the CSV extract exists internal — none tests/fixtures/reference_datasets/live_series/evia_2021/*, tests/fixtures/reference_datasets/README.md (hotspot), tests/test_fixtures_live_series.py W0.5 · yes · S · build (Sonnet) rasters regenerated from the CSV by firms_points are byte-identical to the vendored ones (prove-it-fails: edit one raster); manifest validates against the replay adapter's model guard: no new provider fetch
W3.1b second real series (Rhodes 2023-07 or Attica 2024-08) operator-run — needs FIRMS_MAP_KEY and egress a builder does not have — docs/data/volume-budget.md, scripts/historical_catalog_manifest.json — · bootstrap (owner fetches with the pack after the budget row lands; a follow-up S issue vendors the result) — —
W3.2 flood depth footprint sourcing spike — — spike (comment-only, no code) — —
W3.3 prod ops: live-worker up, three watches, 24 h poll health, #1155's manual box, RAM measurement bootstrap (owner) — — — — —
W4.1 docs: Phase 15 closeout PARTIAL — plan.md, showcase-script.md, CLAUDE.md describe Phase 15 as defined-not-open and Moment 4 as target; docs/CLIMATE_LAMA.md §16 still says every compute writes a usage_events row (:3227, 3288) internal — none docs/plan/phase-15-live-risk.md, docs/plan/plan.md (hotspot), docs/plan/showcase-script.md (hotspot), CLAUDE.md (hotspot), docs/CLIMATE_LAMA.md (hotspot, §4 API table, §6, §7, §16) after every W0–W3 build issue · no · S · build (Sonnet) Moment 4 steps flipped only for the steps walked; status log entry; nav-of-nine wording matches App.tsx::NAV_ITEMS; §16 states that feed auto-computes are unmetered guard: no code or test changes; defer: the UI doc → ui W2.6
W4.2 release + prod walk + mid-review owner-run — needs prod SSH, pg_dump, a coordinated two-repo release — docs/plan/mid-review-phase-15.md — · bootstrap (owner + orchestrator) per D13's ordering checklist (migrate → restart live-worker + beat → assert alembic_version = 0092 → first tick logged) —
W2.0 feat(ui): client foundation — info features, watch/feeds/observations/delta methods, AuthContext features PARTIAL — six live methods exist (client.ts:1644-1710); no getInfo; AuthContext has no features; Resources has liveEventId only (discovered in contract.spec.ts:169, assembled at :212) internal — none src/api/client.ts (hotspot), src/types/index.ts (hotspot), src/auth/AuthContext.tsx, src/test/fixtures/liveEvents.ts, src/test/liveEventsClient.test.ts, tests/e2e/smoke/contract-map.ts (hotspot), tests/e2e/smoke/contract.spec.ts CortoMaltese3/climate-lama#<W0.2>, #<W1.5>, #<W1.6>, #<W1.7a>, #<W1.7b> · no · M · build every new method has a contract-map entry (prove-it-fails: remove one entry and contract.spec.ts goes red — it derives the list from api); liveFeedId discovered in contract.spec.ts; fixtures from real payloads guard: no callers changed; guard: no page code
W2.1 feat(ui): /live shell — watch-list rail, nav entry, redirect, off-state MISSING — no /live route (checked App.tsx, src/pages/); NAV_ITEMS 8 with {to, key, adminOnly?} and the !item.adminOnly \|\| isAdmin filter; RedirectPreservingSearch takes a static to (MatrixBatchRedirect is the param-rewriting precedent); liveEventChip.ts is imported by both PortfolioDetailPage.tsx:22 and components/live/EventHeader.tsx:4; no liveEventChip.test.ts exists (the chip is tested through EventHeader.test.tsx) the Live tab appears src/App.tsx (hotspot), src/pages/LivePage.tsx, src/components/live/{WatchListRail,WatchCard,LossSparkline}.tsx, src/lib/liveEventChip.ts (delete), src/components/live/EventHeader.tsx + EventHeader.test.tsx, src/pages/PortfolioDetailPage.tsx, src/i18n/locales/en/common.json, src/i18n/locales/en/live.json (hotspot), tests/e2e/smoke/events.spec.ts, src/test/LivePage.test.tsx W2.0 · no · M · build nav entry mounts only on features.live_mode (prove-it-fails: features.live_mode=false in the test → no entry); params-aware redirect keeps ?portfolio=; events.spec.ts:386 asserts the redirect lands on /live/:eventId?portfolio=:id and the chip text still matches; both importers read event.kind and EventHeader.test.tsx asserts the Replay chip comes from kind, not source; off-state copy has no "alert" guard: event view untouched (W2.2)
W2.2 feat(ui): event view — AOI + boundary layers, follow-latest scrubber, inspector, assets drawer PARTIAL — LiveEventPage has rail + map + scrubber + ?snapshot=; SnapshotScrubber has playing/nowIndex and no follow-latest; LiveEventMap draws no AOI or boundary layer; no drawer component under components/live/ the event screen becomes the Live event view src/pages/LiveEventPage.tsx (hotspot), src/components/live/{LiveEventMap,SnapshotScrubber}.tsx, src/components/live/{Inspector,AssetsDrawer}.tsx, src/map/styles/live.ts, src/i18n/locales/en/live.json (hotspot), src/test/LiveEventPage.test.tsx, component tests W2.1 · no · M · build follow-latest advances on a new snapshot and a manual scrub turns it off (prove-it-fails: with follow-latest on, a new snapshot must move activeIndex); AOI outline renders from event.aoi (test: null AOI → no layer); resize + stacked-below-sm test guard: no create/close (W2.3); guard: no delta (W2.4)
W2.3 feat(ui): create-watch dialog, close, feed status strip, poll now MISSING — checked src/components/live/, src/pages/LiveEventPage.tsx (only a compute button), src/api/client.ts (no create/close/feed methods) analysts create, close and poll from the UI src/components/live/{CreateWatchDialog,FeedStatusStrip}.tsx, src/pages/LivePage.tsx, src/pages/LiveEventPage.tsx (hotspot), src/i18n/locales/en/live.json (hotspot), tests W2.2 · no · M · build ANALYST gating mirrors the API; AOI = book extent or current map.getBounds(); feed params form per kind (no host field); the dialog never sends kind (the feed sets it); prove-it-fails: a VIEWER sees no Create button guard: no observation upload UI (API-only this phase); defer: observation upload UI → Phase 16
W2.4 feat(ui): two numbers + delta panel PARTIAL — EventLossHeadline shows one figure; no delta component exposed count/value beside the loss; "since the previous observation" panel src/components/live/{EventLossHeadline,DeltaPanel}.tsx, src/pages/LiveEventPage.tsx (hotspot), src/i18n/locales/en/live.json (hotspot), tests W2.3 · no · S · build aria-live mirror; no "alert" string (prove-it-fails: the grep test goes red on an injected "alert"); delta absent (E_RESULT_VECTOR_ABSENT or a first snapshot) → honest empty state (test) guard: no change to the annual StatsBar; guard: no chart library beyond recharts
W2.5 feat(ui): Home "Live now" card MISSING — five cards exist on useCardFetch (components/home/*Card.tsx); none reads live events Home shows open watches src/components/home/LiveNowCard.tsx, src/pages/HomePage.tsx, src/i18n/locales/en/home.json, tests W2.0 · yes (∥ W2.1) · S · build hidden when features.live_mode is false (prove-it-fails: flag false in the test → card absent); empty state; useCardFetch, never useIntervalFetch guard: no edit to the other five cards; guard: no polling
W2.6 test(e2e): live.spec.ts + Moment 2 re-walk + UI doc MISSING — events.spec.ts only; compose.yml already mounts the backbone's tests/fixtures read-only and copies fixtures in its seed service (:231, 288-299); compose.ci.yml is a BuildKit-cache overlay and e2e-smoke.yml checks the backbone out at main — neither needs an edit internal — exit criterion 2's machine twin tests/e2e/smoke/live.spec.ts, tests/e2e/smoke/compose.yml (seam), docs/CLIMATE_LAMA_UI.md, src/i18n/locales/en/live.json (hotspot) W2.3, W2.4, CortoMaltese3/climate-lama#<W1.8> · no · M · build (Opus — seam) spec creates a watch with the replay feed, polls twice, sees the map/scrubber/delta update; Moment 2 steps 8–9 assertions on the new route; prove-it-fails: run the spec with the series copy line removed → red; seam box: the series path resolved inside the smoke stack and recorded; the post-merge e2e-smoke.yml dispatch is the orchestrator's (builders have no Docker) guard: no new workflow; guard: compose.ci.yml untouched

Dependency line for the split: W0.1 → {W0.2, W0.4, W0.5} → W1.1a → W1.1b (also #1271, W0.3) → W1.2 → {W1.3 ∥ W1.4} → W1.6 → W1.5 → W1.7a → W1.7b → W3.1a → W1.8 → W4.1; UI W2.0 (after W0.2, W1.5, W1.6, W1.7a/b) → {W2.1 ∥ W2.5} → W2.2 → W2.3 → W2.4 → W2.6 (after W1.8). Create-agent groups (backbone): {W0.1, W0.2, W0.4, W0.5} · {W1.1a, W1.1b, W1.2, W1.3, W1.4} · {W1.5, W1.6, W1.7a, W1.7b, W1.8, W3.1a, W4.1}; UI: one group, inline.

What the split must not decide (it is already decided): D1–D18, the contracts above, guardrails 12–22, the buildable kinds (W3.1b, W3.3, W4.2 are owner ops; W3.2 is a spike), the reuse of #1240 and the closure path of #1155. What the split must do: ground every card at split time (git show origin/main:<path>), size with /analyze (seam floors: W0.3, W1.8, W2.6), write cross-repo deps fully qualified and same-repo deps as bare #N, put the ## Execution block on every issue, put #1240 and #1155 on the milestone with gh issue edit --milestone (their bodies are never edited; #1155's remaining scope — the manual prod box — is stated in a comment), and record the observed sdk-smoke.yml duration before the series leg lands (the only number today is the 25-minute ceiling).

Reassess record — split-readiness pass (2026-09-08, fast, 3 agents)

19 claims: 13 confirmed, 5 corrected, 1 refuted in the plan's favour; 11 items added. Corrected: the lifted dispatch helper is the row-creation seam only (dataset_ingest.py::_create_ingest_jobs builds pending-ingest params the tick cannot supply, so it keeps its signature and delegates); the api-side reclaim sweep finds a leaf through Job.params["file_path"], not set_staged_source; W1.8's real-stack proof is routed like W0.3's (orchestrator dispatch, Refs); W4.1 and W4.2 were orphans of the orchestration sketch; EventHeader.tsx also imports the chip helper W2.1 deletes and no chip test file exists. Refuted (in the plan's favour): the series fixture needs no compose.ci.yml or workflow edit. Added: the hook signature on_snapshot_committed(*, org_id, dataset_id) -> bool opening its own org-scoped session (the call site is sync, after the async commit closed); kind written by feed creation and never by the dialog; counts derived, with hazard_datasets.live_feed_id replacing a counter nobody would increment; sdk-smoke.yml's ready-line guard learns E2E_READY_SERIES_GRC; poll_live_feed shares the lock and re-checks the flag; the explicit GiST index and the origin String-not-ENUM deviation; rasterise_csv_files moves too; E_RESULT_STALE has no status entry today (400) and gains 409 with W1.6; the regenerate script lives at sdk/python/scripts/; the hetzner override has no env_file; a redis.RedisError degrades the tick to a skip; the template sweep (prove-it-fails boxes on ten cards, seam boxes on four, out-of-scope lines on five, "checked where" on two, a bare same-repo dep). Rejected and left out: a docker-compose*.yml edit for new settings (every service carries env_file); helpers.ts for the contract map; a quota charge on feed snapshots (bounded by max_snapshots; recorded in D15); a §16 metering doc fix as an amendment (it is W4.1's).