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_KEYwith 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:
- Numbering / precedence (D1) — Live Risk takes 15; the revamp stub moves to 16 and the Live tab pilots its patterns.
- The feed abstraction (D3) and the choice of exactly three adapters plus a push seam; no sensor/point tables.
- Auto-compute at the commit callback with
notify=false(D5, D15) rather than a new notification type or a user-facing toggle per job. - 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.
- Replay-on-real-data as the presentation mode (D10) with original
valid_at— the honest choice over accelerated fake timestamps. - No flood feed adapter (D12); the peril rides the generic ingress and a sourcing spike decides.
live-workeras an un-profiled compose service on the worker image (D13) instead of a second beat or an in-compute-queue tick.- A fourth showcase moment (D18) and Goal C's enumeration edited.
featureson/v1/info(D8) as the UI's feature-flag source.- 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 withspatial_index=Falseand the GiST index created explicitly by the migration (op.create_index(..., postgresql_using="gist")), the house pattern ofmodels/hazard.py:121;exposure_dataset_id uuid NULLFKexposure_datasetsON DELETE SET NULL,auto_compute boolean NOT NULL DEFAULT false,created_by_user_id uuid NULLFKusersON 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 writeskindexplicitly (see the API contract).hazard_datasets+=live_feed_id uuid NULLFKlive_feedsON DELETE SET NULL(indexed) — which feed produced a snapshot; stamped by the ingest commit fromparams["live_feed_id"]besidelive_event_id; NULL for a pushed observation and for every Phase 13 snapshot.live_feeds:id uuid PK,org_id uuid NOT NULLFKorganizationsRESTRICT(indexed),live_event_id uuid NOT NULLFKlive_eventsON 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 RLSorg_isolation; listed in_ORPHANED_ORG_SCOPED_TABLES. Nosnapshot_countcolumn: every count is derived — feed-levelCOUNT(hazard_datasets WHERE live_feed_id = …)(themax_snapshotscheck at poll time), watch-levelCOUNT(… 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'score/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_createdis the only composite today). Deliberate deviation insidejobs: itsstatusandjob_typeare pg ENUMs (models/job.py:29, 35);originisString+CheckConstrainton purpose (themodels/risk_surface.py:157/SurfaceStatusprecedent) — a builder must not "match the file".models/enums.py+=LiveEventKind,LiveFeedKind,JobOrigin(StrEnums);models/live_feed.pynew;downgrade()reverses all five tables and is exercised throughmigration_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).