Status: DRAFT for sign-off (2026-07-05). This is the strategic umbrella doc: where rise-eo is going (the north-star), how we get there in small releases (the roadmap), and the model contract that lets project teams plug their own models into rise-eo. It feeds the numbered specs in
fsd/specs/— decisions here graduate into specs before implementation. Infra ground-truth for the cloud phases lives indocs/reference/AZURE_INFRA.md(theriseproject). Current state: the pinned "Order of work" issue and the open PRs.The F1–F5 decisions baked into §3 are proposed, pending the user's sign-off.
A researcher developing a crop/land model for some region (say Afghanistan) can, with minimal cloud setup:
pip install rise-eo(a GitHub link first; PyPI later).- Create training data from a list of known-label polygons (e.g. farm plots), possibly across different years / date ranges / mosaic intervals depending on the model: rise-eo downloads the satellite data → builds datacubes for those polygons → flattens them into training arrays. The user never has to think about "flattening."
- Train their own model — sklearn, PyTorch, TensorFlow, anything. rise-eo does not care. Training stays permanently on the user's side.
- Write a small model adapter (see §3): declare the feature transform, load the model, run predict, shape the output.
- Run inference at scale by giving rise-eo a region of interest + startdate + enddate + mosaic interval: rise-eo tiles the ROI into S2-geometry grids, downloads the imagery, builds inference datacubes for each grid, runs the model over them, and writes the results as COGs + a STAC catalog. The user never has to think about "tiling the ROI."
- View outputs as XYZ map tiles through a hosted TiTiler (infra-side, raapid).
The invariant that makes this tractable: every stage works locally (Mode A) and scaled on the cloud (Mode B/C) with the same code. Cloud is a backend, never a rewrite. This is the promise we protect at every step.
Moved to ARCHITECTURE.md §5 (spec 41 D9: the code map,
invariants and modes have one home). Modes A (fully local), B (cloud data + compute, local
control and training) and C (fully cloud inference) — B and C were both proven on the real
cluster 2026-07-29.
Moved to ARCHITECTURE.md §2. The
driver is a thin, portable job submitter (laptop · VM · AML job · later a Function); the data plane
is heavy and must be cloud-colocated. What it costs when that line is crossed is measured in
docs/findings/cloud-overhead.md.
Moved to ARCHITECTURE.md §6
(same D9 reason as §2.1/2.2). L0 core · L1 seams · L2 deployment backends · L3 the project's
ModelAdapter · L4 product surfaces. The shipped L2 backends are local (Snakemake) and Azure ML
— this section previously named Azure Batch, which was the earlier plan, not what runs.
Moved to ARCHITECTURE.md §3. Training cubes come from
known-label polygons and are tiny; inference cubes come from S2 grid cells tiling an ROI and are
large. Same builder — what differs is the source of geometries and what happens after. The
opposite economics are measured in
docs/findings/workload-regimes.md.
Intended users shouldn't say "flatten" or "tile the ROI." rise-eo grows a high-level API (§4);
today's flatten / ROI-tiling / builder become its internals.
Download + datacube creation are the expensive, billable operations. So rise-eo runs every cheaply-computable check up front and refuses to trigger the heavy fan-out if any fails — a "preflight." The goal: know before you spend whether a run can succeed. Checks that cost nothing (no download) include:
Tmatch — inferenceT = ceil((enddate-startdate)/mosaic_days)(calendar mosaic) equals the model'sn_timestamps(§3.3). This alone catches the most common deploy failure.- band availability —
required_bandsare obtainable from the source. - ROI sanity — non-empty, intersects the source's data coverage / catalog.
- adapter loads and declares a coherent, self-consistent spec (bands, output, T).
- storage + credentials reachable (blob writable, secrets present).
Preflight is a first-class product feature (users iterate on config safely) and a cost guardrail (no surprise Batch bills for runs that were doomed at config time).
A wrong early choice here forces broad rewrites, so each gets a deliberate spec before we scale:
- Catalog format → STAC (§6).
- Storage URI abstraction → fsspec (good) + MSI/adlfs + GDAL-VSI auth (to prove).
- The model contract (ModelAdapter) → §3.
- Runner / control-plane seam boundary →
runner=local|batchnever leaks into the task CLI. - Model-artifact packaging → the model bundle (§3.4).
Extracted from the legacy demo_02_model_train + model/demo_model_deploy.py, generalized.
The feature transform (e.g. mask_invalid_and_interpolate → compute NDVI/NDRE/GCVI/SAVI → remove raw bands) appears identically in the legacy train and deploy code, copy-pasted
— a classic train/serve-skew trap. Rule: exactly one feature-transform definition, run by
rise-eo in BOTH training-data-generation and inference. [F1]
class ModelAdapter(Protocol):
# --- declarations rise-eo reads to build compatible datacubes & outputs ---
required_bands: list[str] # e.g. ["B02","B03","B04","B05","B06","B07","B08","B11","B12"]
n_timestamps: int # T — the number of mosaic windows the model was trained on.
# rise-eo DERIVES T from startdate/enddate/mosaic_days (calendar
# mosaic, spec 15) and requires an exact match. [contract = same T + bands]
output_dtype: str # e.g. "uint8"
output_nodata: int | float # e.g. 255
output_band_names: list[str] # 1 name = categorical map; N = probabilities/regression
# --- feature engineering: ONE definition, used at train AND inference [F1] ---
feature_sequence: list[tuple[callable, dict]] # rise-eo bands.modify vocabulary (primary)
# def features(self, datacube, band_indices) -> (data, band_indices): ... # arbitrary escape hatch
# --- model lifecycle ---
def load(self) -> None: ... # load artifact ONCE per worker
def datacube_to_X(self, features, band_indices): ... # reshape features → model input
def predict(self, X_chunk): ... # raw output; rise-eo owns the chunk loop [F2]
def to_output(self, raw) -> "Output": ... # → (bands, H, W) + dtype/nodata/names [F3]rise-eo owns (both modes): datacube/array I/O via the storage seam; running feature_sequence;
the predict chunking loop (predict_batch_size, default = whole tile) [F2]; assembling
the output array into a COG using the datacube's transform/crs; and building the
STAC catalog over the outputs. The user owns only: the feature declaration, the
reshape, the model call, and the output shaping.
The model's input width is fixed at train time (e.g. RF sees T×B). The contract is: same
T and same bands. rise-eo validates this before any download/build:
T(timestamps): thanks to spec-15 calendar mosaicing,Tis a pure function ofstartdate/enddate/mosaic_days—T = ceil((enddate - startdate) / mosaic_days)(final window upper-inclusive). So rise-eo computes the inferenceTfrom the user's chosen dates/interval and checksT == adapter.n_timestampsat config time, before a single tile is fetched. (This is a concrete payoff of the calendar-mosaic decision.)- bands:
adapter.required_bandsmust be obtainable from the requested source.
A mismatch refuses to trigger the run with an explanatory error ("your startdate/enddate/ mosaic_days give T=18 but the model needs T=19") — never a silent reshape failure deep in a fan-out after money has been spent on downloads.
Honest asymmetry (surfaced, not prevented): the demo trains on per-field median samples [F4] but predicts per pixel. That's a legitimate modeling choice — rise-eo documents it, doesn't block it.
For deploy, especially on cloud workers, the adapter code + model artifact(s) + the spec (required bands, output spec, feature sequence) must travel together and be loadable anywhere. rise-eo defines a self-describing bundle and can push/register it (later: to ACR / blob / an AML-registry-like store). [F5: code-first — a Python class referenced by import-path/entry-point in config; no no-code path early.]
| Concern | Owner |
|---|---|
| Feature engineering definition | user (declared) |
| Running the feature transform (train + inference) | rise-eo |
| Reshape features → model input | user (datacube_to_X) |
| Model load / predict | user (any framework) |
| Predict chunking/batching loop | rise-eo |
Output → (bands,H,W) + dtype/nodata/names |
user (to_output) |
| Array → COG (+ transform/crs) | rise-eo |
| STAC catalog of outputs | rise-eo |
| Training the model | user (permanent) |
| ROI → S2 tiles, download, datacube, flatten | rise-eo (hidden behind verbs) |
Sketch (signatures to be firmed in specs):
rise_eo.create_training_data(
label_polygons, dates, mosaic_days, bands,
feature_sequence, aggregate=None, # "median_per_id" | None | callable [F4]
runner="local", storage=..., # runner + storage are seams (local|batch, local|blob)
) -> TrainingArrays # data/ids/labels/metadata (hides flatten)
rise_eo.run_inference(
roi, dates, mosaic_days, model_bundle,
runner="local", storage=...,
) -> COGs + STAC catalog # hides ROI→S2 tiling, datacube build, COG/STAC
rise_eo.deploy(model_bundle, ...) # register a bundle for scaled inferencerunner= and storage= are the seams: the same call runs Mode A (local, local disk) or
Mode B/C (Batch, blob) by config alone — no code change.
ROI → S2-grid tiling (inside run_inference(roi=…), P0.75 — the tiling itself landed as
rise_eo.grid, spec 19) is a port, not an invention — the
legacy rsutils.s2_grid_utils.get_s2_grids_gdf already does it, and
fetch_satdata/notebooks/demo_preparation.ipynb pins the intended recipe:
get_s2_grids_gdf(roi_geojson_4326, grid_size_km=5, scale_fact=1.1, res=None)— mapgrid_size_km→S2 level (5 km → res 11, as in the legacy100_random_grids),s2.polyfillthe ROI's convex hull at that level, keep cells that intersect the ROI, then scale each cell byscale_fact(1.1 → 10 % overlap per side) so adjacent grid cells don't leave seams at mosaic time.gpd.overlay(grids_gdf, roi_gdf)— clip the scaled grids to the ROI → the per-cell geometries that feed datacube creation (each grid cell = one inference datacube = one per-cell task).- Deps:
s2(from s2 import s2, givespolyfill) +s2cell. ✅ ported intorise_eo/grid.py(spec 19); consumed by the P0.75 wrapper, not P4. Not needed for P0'screate_training_data.
Each phase is a shippable release to show the team. Infra-ask flags where we must propose
a raapid-infra change to the admin (we never edit it ourselves).
Everything below is background; this is what we are driving at, and anything not on this path is a distraction until it ships:
A researcher runs the same rise-eo pipeline on Azure at scale —
runner="aml"/storage="abfss://…"is configuration, not a rewrite. That is P2 → P4 (TODO #41). Audience: a researcher who would actually use rise-eo, so the self-serve bar applies —pip install, docs, adapter-writing, error messages that teach.
Wording widened 2026-07-21 (user), from "on Azure Batch at scale" /
runner="batch". The promise being locked is the seam — cloud as a swappable backend — not a product name, and hardcoding the vendor's product into the north-star is precisely the failure mode that cost us a session to detect. Why Batch was displaced: run-book36-runner-fork-probe(tagdocs-archive-2026) measured the project's Batch account at a 6 vCPU dedicated core quota against a 64-core pool VM — it cannot allocate one node — while the AMLd16cluster offers 512 cores today under the same managed identity spec 31 already proved against blob. Full evidence:docs/reference/AZURE_INFRA.md§3.1. Batch is dropped, not deferred (user, 2026-07-21 — strict YAGNI): AML ships the demo, and the seam is already demonstrated by local-Snakemake ↔ AML. Revisit only if something actually needs it.
Everything local is already done and proven on real data (P0 → P0.9), and the storage half
of the cloud story is proven too (P1: runbook 31-p1-datacube-on-blob.md green,
34-download-to-blob.md green on both sources). The single missing piece is the runner.
rise credentials + VPN cannot run the Batch
path at all. So the honest shape of the demo is "the local path is theirs to run; the cloud
path is the same call with two arguments changed, which we drive." Protecting that — one code
path, cloud as a backend — is the demo. If the Batch runner ends up needing its own pipeline
code, the demo has failed even if it runs fast.
What is left, in order (detail: docs/reference/AZURE_INFRA.md §3.1/§7/§8, TODO #41):
Decide Batch vs AML.✅ RESOLVED 2026-07-21 → AML (cluster-<proj>-d16), by measurement rather than argument: run-book36-runner-fork-probe(tagdocs-archive-2026) +docs/reference/AZURE_INFRA.md§3.1. Batch dropped.- Write spec 36 (the scale-runner spec) — settles §7's remaining questions: where the driver
runs, blob data layout, the job environment/image, the
--runner=seam, idempotency under retries, telemetry. (§7's task-granularity question is already answered — see below.) - Implement the runner seam + fix the local Snakemake sentinels' blob-unsafety (both TODO #41; the second falls out of the first, since §8.1's atomic-rename publish replaces the sentinels).
Two things got materially cheaper on 2026-07-21, and both are worth naming:
- Zero infra asks. The old plan needed two platform-admin changes (a Batch core-quota bump and
probably
max_tasks_per_node). The AML clusters are provisioned, quota'd, and identity-attached today — so P2 no longer waits on anyone. The project's "first infra proposal" is deferred indefinitely. - No container build blocking the path. "Container image + ACR push — the largest genuinely new
build" was a Batch requirement. AML builds and versions the job environment itself, so an image is
an optimization, not a gate. (
az acr buildis available server-side if we ever want one — the registry is Basic SKU with public access, so no local Docker is needed either.)
Task granularity is decided (user, 2026-07-21): one dispatched unit = a shard of input.csv,
executed by the existing local Snakemake runner inside the job. The cloud runner shards the work
list and launches copies of code that is already proven — the strongest form of "cloud is a backend,
not a rewrite." Retry/resume is per-shard, made safe by per-cube skip-if-exists (§8.1).
✅ No spike is needed first. GDAL/VSI auth under MSI — long carried as the technical
unknown (docs/reference/AZURE_INFRA.md §7.3) — was solved and proven on real Azure by spec 31
(rise_eo.raster.rio_open → /vsiadls/ + fresh token; runbook 31-p1-datacube-on-blob.md green
2026-07-18). §7.3/§8 are now marked resolved. The residual is narrower and lands in P4, not
here: GDAL writes to blob for inference-output COGs are still unproven (rio_open raises on a
remote mode="w" by design), i.e. TODO #39. P2 is design + build, not discovery.
Not on the path (real, but do them when we hit them — LIMITATIONS.md): more data sources,
xarray/zarr, the config-only adapter, tile serving. Serving (P5) is the natural next demo
after this one, not part of it.
| Phase | Ships | Demo | Infra-ask |
|---|---|---|---|
| P0 | rise-eo pip-installable (GitHub); high-level verb skeletons; STAC-aligned catalog (§6) | pip install rise-eo, local download→datacube→flatten via create_training_data |
none |
| P0.5 | ✅ DONE (spec 18, 2026-07-06) — ModelAdapter contract + legacy train/deploy reimplemented on it, fully local. src/rise_eo/model/ (adapter/features/engine/bundle), run_inference real, create_training_data(adapter=…), self-describing bundle. |
Mode A end-to-end: EuroCrops RF train → inference → COG + STAC, one plug-in adapter (tests/manual/deploy.md) |
none |
| P0.75 | ✅ DONE (spec 21, 2026-07-07) — Local ROI inference verb (completes Mode A). run_inference(roi=…) chains tile the ROI (rise_eo.grid.roi_to_s2_grids) → build one datacube per grid cell → infer → COG + STAC (+ optional display merge) in a single call, all local. The per-cell build+infer is one runner-dispatched unit-of-work (workflows/infer_task.py + Snakefile → Batch swaps in at P4 unchanged, not a second pool). merge=True|"reproject" (strict vs lossy display merge). Imagery assumed present in the catalog — inference never calls CDSE (conserve quota, SO-6). (create_training_data(roi=…) deferred — labelled field shapes need no ROI→cell tiling.) |
Mode A: one call turns an ROI GeoJSON into per-cell crop-class COGs (tests/manual/roi_inference.md) |
none |
| P0.9 | ✅ DONE (spec 23, 2026-07-10) — Local-completeness gate + team run-book. demos/e2e_austria.py runs the whole local pipeline on fresh real CDSE data (download → jp2→COG → datacube → flatten → train → bundle → ROI build+infer → COG/STAC/merged), a reusable template (swap --roi/--train), cross-UTM-zone-safe (merge="reproject" area-dominant/merge_crs). Adds decomposed download timing (transfer vs COG-convert) + a throughput probe (factor out link/VPN), the plan_download guardrail (missing imagery → actionable rise_eo.download plan; verbs never auto-fetch), and a no-download estimate_run (ETA for any region/window/bands). Doc: demos/E2E_AUSTRIA.md. |
the go-to "how rise-eo runs locally" doc + trustworthy timings | none |
| P1 | Storage seam on Azure: adlfs/MSI read+write to the rise project storage; GDAL-VSI auth proven |
build a datacube locally but I/O against rise blob (over VPN) |
none (uses existing) |
| P2 | Azure AML runner for datacube fan-out (the runner seam; spec 36) | N datacubes built across the autoscaled rise AML d16 cluster |
none (was: Batch quota bump + max_tasks_per_node — moot since the fork went to AML) |
| P3 | 🟡 PARTIALLY DONE (spec 39, 2026-07-24, pending cluster validation) — create-training-data-at-scale. create_training_data/flatten_training_data gain runner="aml": an optional download phase (api.download, reused), the spec-36 build fan-out (reused), and a new single-node flatten reduce (workflows.runners.run_aml_flatten, D3 — flatten is a reduce over ALL cubes, not a per-cell fan-out) whose compact output lands back on the laptop (api._land_local, D4, storage.transfer). The driver stays control-plane-only (ADR-0004) — only the flattened array (+ optional features.npy, computed on the driver, ADR-0020) comes home, never the raw cubes. All unit tests mocked at the AML-client boundary — run-book 39-training-data-on-aml (tag docs-archive-2026) Phases 0-2 are the pending real-cluster validation. Docs: docs/adr/0020, CONTEXT.md. |
Mode B: laptop triggers cloud download→build→flatten, pulls the flattened arrays | none new |
| P4 | ✅ DONE (spec 38, 2026-07-23, pending cluster validation) — Inference at scale. run_inference(roi=…, runner="aml") dispatches the P0.75 per-cell build+infer unit-of-work onto the rise AML cluster, reusing the P2 datacube-fan-out runner (workflows.runners.run_aml_inference + a new node entrypoint workflows/infer_shard.py) — a thin runner/dispatch swap (runner=/storage=/runner_kwargs= config), no new pipeline algorithm, plus the I/O-seam fixes the swap exposed: remote-dst COG (raster.cog.to_cog, closes TODO #17), bundle-loaded-once-per-node (closes TODO #25's root cause), a dedicated inference AML Environment (adapter + deps, an operator run-book step), and a one-node adapter-import smoke before the fan-out. 31 tests, all mocked at the AML-client boundary (no test needs Azure) — run-book 38-inference-on-aml (tag docs-archive-2026) Phases 0-3 are the pending real-cluster validation. Docs: docs/adr/0001, docs/adr/0002, CONTEXT.md. |
Mode C: the P0.75 ROI verb fanned out across AML nodes | maybe scale max_nodes |
| P5 | Output STAC + hosted TiTiler / XYZ | outputs viewable as web tiles | TiTiler hosting (infra) |
| P6 | Deploy/registration UX; model-bundle push/register | one-command deploy of a bundle | model store (infra) |
P0–P1 need zero infra changes — pure de-risking, two team-visible releases before we're
ever blocked on someone else's terraform apply. The first infra proposal is P2 (Batch quota).
The demo target (§5.0) was met on 2026-07-29, when the cluster demo ran. These goals followed it, in order. The current goal's state lives in its tracking issue and the pinned "Order of work" issue.
| Goal | Set | Status |
|---|---|---|
| rise-eo as an installed module, driven from a notebook | 2026-08-21 | ✅ Done (maintainer, 2026-10-07). A notebook in a separate repository, with rise-eo installed as a pinned dependency, ran the full pipeline and the work was demoed. Built by specs 54–57 and issues #78–#82; v0.1.0 was tagged on 2026-09-04. |
| Collection-agnostic verbs (spec 58) | 2026-09-04 | |
| Contributor readiness (spec 102) | 2026-09-29 | Current. See #102. |
Moved here verbatim when the old TODO file became a stub (spec 41 D8/P2, 2026-07-30). Point-in-time: this is what the ordering looked like on 2026-07-02 — steps 1 and 2 have since happened, and §5's phase table above is the live plan. Kept because it records why the order was chosen.
The item numbers are stable IDs, not priority order (they are now GitHub issue numbers). The intended order of the big efforts:
- Finish v1 end-to-end — download → datacube → flatten, local Snakemake runner (current focus; datacube module #5 is next).
- Azure setup + Batch processing for datacube creation — the cloud-agnostic
scale-out end goal (
specs/10). Comes before any source extension. - Source extension, incrementally (issue #11; promotes the source contract to the
sources/base.py: SourceABC, OQ-3): CDSE S2 L2A (done) → MPC S2 L2A → CDSE S1 GRD / S1 RTC → MPC S1 → other products (CHIRPS, ERA5, …). Benchmark against— DONE and DECIDED 2026-07-31; issue #12 closed. See §7.1 below.rslearn(issue #12)
The cross-cutting perf track that used to sit under this heading (datacube-creation speed at scale, the 3-part benchmark-first plan and its five parked optimization candidates) moved to issue #15, which is that item — see its migration comment.
Do it in P0, before Azure. Rationale: the catalog format is surface #1 — it ripples into
inference outputs, cross-source catalogs (CDSE + future sources), and TiTiler (which expects
STAC). Deciding late = migrating data and rewriting the reader twice. It's cloud-agnostic and
locally testable, and there's a natural bridge (our catalog is GeoParquet; STAC has the
stac-geoparquet serialization; the legacy deploy notebook already builds a pystac catalog
of output COGs).
Scope guard: "our catalog is STAC-valid and round-trips to STAC Items" — not "we run
a STAC API server" (that's L4/infra/later). A tight spec will name exactly which STAC fields we
populate (proj:epsg, proj:shape, proj:transform, datetime, bbox, COG asset) and stop.
Applies to both the post-download catalog and the inference-output catalog (one catalog
abstraction).
rise-eo will not use rslearn as a data-download source — not wholesale, not as a hybrid source
behind an extra. Plan B stands; spike/rslearn does not merge into main. Issue #12 is
closed.
The framing that decided it: rise-eo is an alternative to Google Earth Engine, for running simpler models at scale over satellite imagery. rslearn is a foundation-model library that happens to ship an acquisition layer. Two tools, two jobs — merging the acquisition layers is a category error.
The successor project: rslearn's real value is its model library and deploying heavy foundation models. That becomes its own project — run rslearn on Azure the way rise-eo now can. The transferable asset is rise-eo's scale-out know-how (the runner seam, the storage seam, the AML environment and identity work), not rise-eo's pipeline code.
What was measured (rslearn v0.1.13 @ a5c50c63, Azure VM, 2026-07-31): rslearn's T is
data-dependent (9 groups where rise-eo gives 10; 7 where rise-eo gives 9 once a period is empty); its
period yields one first-coverage scene where rise-eo takes a per-pixel median; the install is
5.3 GB with no lite path and does not import out of the box; its blob reader runs ~22 MB/s
against rise-eo's ~107 MB/s via /vsiadls/, which vindicates spec 31's design for rise-eo's
workload; and it ships no distributed runner at all. Against that, rslearn has 50 concrete
data-source entry points to rise-eo's 2 plus a foundation-model zoo rise-eo has no analogue for — the
strongest argument for adoption, and the reason the successor project exists.
One finding carries straight into that project: rslearn reads and writes ADLS Gen2 under
managed identity unmodified, for the price of pip install adlfs. So rslearn-on-Azure does
not begin with a storage problem — it begins at the runner.
Two byproducts land on rise-eo regardless: rslearn's lazy per-read MPC signing answers issue
#32's design question, and its class_path + init_args pattern is a good model for the
Source ABC that issue #11 still owns — which does not exist yet (sources/__init__.py:5;
api.py:264,413 dispatch on a hardcoded ("cdse", "mpc") check, duplicated).
Evidence: spike/RSLEARN_SPIKE_REPORT.md — §6.4 is the
decision, §6.5 explains why it cut differently from the report's own recommendation. The report
also supersedes docs/findings/RSLEARN_COMPARISON.md (2026-07-06), which was wrong
about rise-eo's calendar-T contract being unique.
- F1–F5 sign-off (§3) — the proposed contract decisions.
— RESOLVED (2026-07-06): sametimestamp_contractstrictnessT+ same bands;Tderived from calendar params and checked in preflight before download (§2.6, §3.3).- Coupling of
TileCatalogto its current schema — determines how cheap the STAC move is. - Where the model bundle is stored/registered on cloud (ACR? blob? AML registry?) — P6.
- Multi-band feature vocab limits — is
bands.modifyexpressive enough, or is the callable escape hatch load-bearing from day one? - Everything in
docs/reference/AZURE_INFRA.md§7–§8 (Batch task granularity, driver host, GDAL-VSI auth, data layout, container image, idempotency at scale).
Maintenance: update phase status as releases ship; fold each resolved decision into its
numbered spec. Cross-refs: docs/reference/AZURE_INFRA.md (infra), the pinned "Order of work" issue (current state),
specs/ (signed-off designs).