# 08 — The API Contract **Parent:** [Architecture hub](README.md) · **Status tags:** `IMPLEMENTED` · `VERIFIED` · `MEASURED` · `NOT RUN` · `OPEN` **Sources of truth for this chapter, all read in full before writing:** | Source | What it establishes | |---|---| | `core/schemas.py` (462 lines) | the binding typed contract: `AnalysisRequest`, `ResultEnvelope`, `HealthStatus`, `SpecialistResult`, `ExecutionTrace`, `Task`, … | | `core/errors.py` (315 lines) | the 23-code error taxonomy, `recoverable` defaults, `scrub_paths` | | `app/space_app.py` (735 lines) | the four Codespace endpoints, `build_space_app()`, the G-1 and return-annotation traps, the five entrypoint requirements | | `gateway/policy.py` (937 lines) | `_CODE_STATUS`, `DEFECT_CODES`, `GATEWAY_ORIGIN_CODES`, `GatewayConfig`, `admit()`, CORS, body validation, `translate_error` | | `gateway/app.py` (691 lines) | `PROXIED_ROUTES`, `BLOCKED_ROUTES`, `COSTLY_ROUTES`, `_proxy()`, the no-retry rule | | `gateway/assets.py` (516 lines) | `AssetStore`, `read_body_bounded`, handle opacity, TTL, capacity | | `deploy/render/main.py` (532 lines) | the `/api/*` gateway mirror (monorepo copy) | | `deploy/render/codespaces.py` (178 lines) | the GitHub Codespaces control-plane client | | `render.yaml` (25 lines) | the Render blueprint's env-var declarations | | `docs/API_CONTRACT.md` (916 lines) | the client-facing specification, read fully | | `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3, §2.3 | the entrypoint requirements and the code-passthrough rule | | `docs/DEPLOYMENT_TOPOLOGY.md` (248 lines) | the active topology and the measured live env-var set | | `docs/FRONTEND_INTEGRATION.md` (417 lines) | the integration guide, and what it says is *not* guaranteed | > **The one rule that governs this whole chapter.** A claim here is only as good as the file it came > from. Where the code and a document disagree, the code is authoritative and the disagreement is > stated. Where neither answers, this chapter writes > `UNKNOWN — not established from the available evidence`. --- ## 1. Scope, and where this subsystem sits The API contract is the **outermost** typed surface of SatQuery AI. Everything inside the system — router, planner, controller, specialists, evidence engine, confidence stage — is reachable only through four HTTP endpoints. There is no fifth door, no streaming channel, and no persistent session. The chapter covers: 1. the **four** Codespace endpoints and their **`/api/*` gateway mirror** (§2–§4); 2. the **cheap / COSTLY** split and what each class is allowed to do (§3); 3. the request and response **envelopes**, with real JSON (§5–§7); 4. the **error taxonomy**, its machine codes and the `recoverable` flag (§8); 5. the **`x-satquery-transport`** header and what it proves (§4.3); 6. the **size and limit** rules (§9); 7. the **CORS allowlist** rule, which is never `*` (§10); 8. the **HARD RULE** that the gateway must not retry `POST /api/infer` (§11); 9. the **verified examples** (§12); 10. the **five entrypoint requirements** (§13); 11. the **two annotation traps** that make a FastAPI app silently wrong (§14); 12. what is `NOT RUN` / `OPEN` / `BLOCKED` (§15) and where the evidence lives (§16). ### 1.1 Two vocabularies, deliberately The system publishes **two** path vocabularies and they are not interchangeable: | Vocabulary | Owner | Path shape | Audience | |---|---|---|---| | `/v1/*` | the inference service (`app/space_app.py`) | `/v1/health`, `/v1/capabilities`, `/v1/analyze`, `/v1/assets` | the gateway, and any direct caller of the Codespace | | `/api/*` | the orchestrator (`deploy/render/main.py`) | `/api/health`, `/api/infer`, `/api/capabilities`, `/api/assets` | the browser | The browser talks only to `/api/*`. The `/v1/*` surface is the Codespace's own; the orchestrator holds the security boundary and is *"the only public door"* (`frontend/assets/js/live.js:54`). The naming is not cosmetic — `live.js` records why the frontend cannot simply use `/v1/*`: > *"The orchestrator's proxied routes (`deploy/render/main.py`). These are NOT the Space's own > `/v1/*` routes -- the browser never talks to the Space directly; the orchestrator is the only > public door."* (`frontend/assets/js/live.js:52-54`) ### 1.2 The contract's own status `docs/API_CONTRACT.md` §8 states the status of each element. Reproduced because it is the contract's own honest self-assessment and it must not be softened: | Element | Status (verbatim from `docs/API_CONTRACT.md` §8) | |---|---| | Endpoint surface (`/v1/health`, `/v1/capabilities`, `/v1/analyze`, `/v1/assets`) | **Fixed** — 3 by the plan, the 4th by the owner ruling of 2026-09-22 | | Request/response shapes | **Existing and tested** — `core/schemas.py` | | Error taxonomy and `code` values | **Existing and tested** — `core/errors.py` | | Error envelope (`{"error": {...}}`) | **Specified here.** The gateway must produce it; the Space's own errors are translated by the gateway | | `POST /v1/assets` | **Implemented**, Option A | | Multipart upload into `/v1/analyze` | **Not implemented**, and not chosen — Option B was rejected | | Authentication | **Deliberately absent** (plan §74) | | Streaming / progress | **Not in v1** | | Rate-limit values | **Not specified by the plan.** The gateway must choose them; ask the maintainer | | Asset TTL, size cap and content-type allowlist values | **Deployment configuration**, not contract constants | > *"**Nothing in the 'Status' column above may be treated as settled if it says 'Not in v1', 'Not > implemented' or 'Not specified by the plan'** — unless the row also names a decision that closed > it. Those are gaps this document surfaces rather than fills."* (`docs/API_CONTRACT.md` §8) --- ## 2. The four endpoints The surface is **four** endpoints. The original plan fixed three; the owner ruling of 2026-09-22 added the fourth by choosing Option A for upload (`docs/API_CONTRACT.md` §2). | # | Method | Path (Codespace) | Purpose | Auth | Class | |---|---|---|---|---|---| | 1 | `GET` | `/v1/health` | Liveness + which models are loaded | none | **cheap** | | 2 | `GET` | `/v1/capabilities` | What this deployment can actually do right now | none | **cheap** | | 3 | `POST` | `/v1/analyze` | Run one analysis request | none | **COSTLY** | | 4 | `POST` | `/v1/assets` | Upload one image out of band; returns an opaque handle | none | **COSTLY** | The endpoint table is declared in code in `app/space_app.py`, where the routes are registered: ```python @api.get("/v1/health") async def health() -> JSONResponse: ... @api.get("/v1/capabilities") async def capabilities() -> JSONResponse: ... @api.post("/v1/assets") async def assets(request: Request) -> JSONResponse: ... @api.post("/v1/analyze") async def analyze(payload: dict[str, Any]) -> JSONResponse: ... ``` (`app/space_app.py:521`, `:549`, `:555`, `:661`) The four-endpoint statement is repeated in the entrypoint's own module docstring: > *"The app itself is where the contract's **four** endpoints are served (`/v1/health`, > `/v1/capabilities`, `/v1/analyze`, `/v1/assets` -- the fourth per the owner ruling of 2026-09-22); > the gateway sits in front of it and holds the security boundary."* (`app/space_app.py:412-416`) ### 2.1 Why the fourth endpoint exists at all `AnalyzeRequest.assets` is `list[str]` — asset *handles*, not bytes — and the original plan defines no upload endpoint. `docs/API_CONTRACT.md` §2.5.1 records the gap as **"NOT IN THE PLAN"** and preserves the phrase *"because it is the finding, and a decision record that deletes the problem it solved is not a record."* The two options were: | Option | Shape | Trade-off | |---|---|---| | **A. Out-of-band upload** — **CHOSEN** | `POST /v1/assets` → `{"asset_id": "...", "expires_at": "..."}`. Frontend uploads first, then calls `/v1/analyze` with the returned ids | Keeps `/v1/analyze` JSON-only and lets the gateway enforce a size limit *before* the JSON body is parsed. Costs one extra round trip | | **B. Inline multipart** — not chosen | `/v1/analyze` accepts `multipart/form-data` directly | One round trip. Couples upload and analysis; a retry re-uploads | The three things the frontend needs — *a per-file size limit, a content-type allowlist, and an idempotency story for retries* — were resolved by Option A. Note the third resolved to **"there is none, a retry mints a new handle"**, and the contract explains why that is a decision rather than an omission: > *"with no request key in the contract, a deduplicating server would have to hash payloads, and a > content-hash handle is exactly the guessable identifier §2.5 forbids."* (`docs/API_CONTRACT.md` §2.5.1) ### 2.2 The multipart form on `/v1/analyze` is specified but not implemented `docs/API_CONTRACT.md` §2.4 documents a `multipart/form-data` request shape for `/v1/analyze`: | Part | Type | Notes | |---|---|---| | `assets` | file, repeatable | 1–2 image files. Field name repeats for the pair | | `request` | text | A JSON string of the `AnalysisRequest` body with `assets` omitted | and then states its own status plainly: > *"**Not yet implemented.** The multipart entry point is part of the gateway's contract but the > reference implementation serves the JSON form only. […] Build the frontend against the JSON form, > which pairs with `POST /v1/assets`."* (`docs/API_CONTRACT.md` §2.4) The shipped handler confirms this: `analyze(payload: dict[str, Any])` reads a JSON body and validates it with `AnalysisRequest.model_validate(payload)` (`app/space_app.py:662-683`). No multipart parsing exists on that path. --- ## 3. Cheap versus COSTLY — the distinction that orders everything The gateway splits its routes into two classes. This is not documentation prose; it is a tuple in the code: ```python #: Routes the gateway rate-limits, because they cost GPU quota or disk. COSTLY_ROUTES: tuple[str, ...] = ("/v1/analyze", "/v1/assets") ``` (`gateway/app.py:199`) and the rate limiter is applied only when the route is costly: ```python # 4. Rate limiting, only for routes that cost GPU quota. Rate-limiting # health checks would make the frontend's load probe fail for no gain. if cost: allowed, remaining, retry_after = self.limiter.check(identity.key()) ``` (`gateway/policy.py:620-623`) | Route | Class | Rate-limited? | May load a model? | Cost | |---|---|---|---|---| | `GET /v1/health` | cheap | no | **never** | CPU only; no GPU, no weights | | `GET /v1/capabilities` | cheap | no | **never** | filesystem + config reads | | `POST /v1/analyze` | **COSTLY** | yes | yes, lazily | the only route that consumes GPU quota | | `POST /v1/assets` | **COSTLY** | yes | no | writes disk; consumes one of a bounded number of handles | `docs/API_CONTRACT.md` §2.4 states the analyze cost in one line: *"Run one analysis. This is the only endpoint that can consume GPU quota."* ### 3.1 Why upload is COSTLY even though it touches no GPU `gateway/app.py` records the reasoning, because grouping upload with analyze is the non-obvious call: > *"`docs/API_CONTRACT.md` section 6 tells the frontend to *"serialize requests"* and warns that every > `/v1/analyze` costs quota. `POST /v1/assets` does not touch the GPU, but it does write to the > Space's disk and consume one of a bounded number of handles (`gateway/assets.py`), so an unthrottled > upload loop is a cheap denial of service against a 5-GPU-minute deployment. It is therefore > rate-limited alongside analyze."* (`gateway/app.py:188-193`) and the two allowlists are kept separate on purpose: > *"This is a separate allowlist from `PROXIED_ROUTES` because the two answer different questions -- > "may this reach the Space at all?" and "does it cost a metered resource?" -- and collapsing them > would make the rate limiter's coverage depend on the proxy allowlist."* (`gateway/app.py:195-198`) ### 3.2 The costly classification is passed explicitly, not derived from the path `_proxy()` passes `is_analyze=path in COSTLY_ROUTES` rather than letting `admit()` infer it: ```python is_analyze=path in COSTLY_ROUTES, ``` (`gateway/app.py:437`) with the reason recorded at the call site: > *"Explicit rather than derived from the path. `policy.admit`'s own docstring says tests pass this so > "a route rename cannot silently disable rate limiting"; passing it here means ADDING a costly route > cannot silently miss the limiter either, which is exactly the mistake this would otherwise have made > for `/v1/assets`."* (`gateway/app.py:432-436`) `GatewayPolicy.admit()` accepts the same override for the same reason: > *"`is_analyze`: override for the "this route costs GPU" test. Defaults to a path check. Tests pass > it explicitly so a route rename cannot silently disable rate limiting."* (`gateway/policy.py:579-581`) ### 3.3 What a cheap route is forbidden to do Requirement 4 of the entrypoint requirements (§13) is the operative prohibition: > *"**Never load a model for a metadata request.** Health and capabilities read artifact *presence* > (filesystem) and configuration, not weights."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) `docs/API_CONTRACT.md` §2.1 states the consequence for the caller: > *"This endpoint is answered **without loading any model and without importing torch** — the device > is resolved from configuration, not by probing the runtime. A liveness probe that built the world > would consume GPU quota to say "I am alive"."* The corresponding implementation note in `app/space_app.py` records that this was *not* free: > *"`docs/DEPLOYMENT_ARCHITECTURE.md` section 3.3 […] two things there *did* import torch: > `build_serving_registry()` (via `Config.device_preference`, a `@property` that calls > `_torch_cuda_available()`) and any read of that property. Both were removed: the adapter enumerates > capabilities from `default_specs()` and resolves the device from environment and configuration only. > The test `test_the_metadata_path_does_not_import_torch` runs the import in a subprocess and asserts > `torch imported: False`."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3, requirement 1) --- ## 4. The gateway mirror — `/api/*` ⇄ `/v1/*` ### 4.1 The mapping `deploy/render/main.py` declares the mapping in its module docstring: ``` Proxied routes (never answered locally) --------------------------------------- POST /api/infer -> POST {codespace}/v1/analyze GET /api/capabilities-> GET {codespace}/v1/capabilities POST /api/assets -> POST {codespace}/v1/assets ``` (`deploy/render/main.py:22-26`) | `/api/*` (browser) | `/v1/*` (Codespace) | Answered locally? | |---|---|---| | `GET /api/health` | — (none) | **yes** — the orchestrator's own liveness | | `GET /api/capabilities` | `GET /v1/capabilities` | no — proxied | | `POST /api/infer` | `POST /v1/analyze` | no — proxied | | `POST /api/assets` | `POST /v1/assets` | no — proxied | The route registration in the monorepo copy: ```python @app.get("/api/health") async def health() -> dict[str, Any]: ... @app.post("/api/infer") async def infer(request: Request) -> JSONResponse: ... @app.get("/api/capabilities") async def capabilities() -> JSONResponse: ... @app.post("/api/assets") async def assets(request: Request) -> JSONResponse: ... ``` (`deploy/render/main.py:444`, `:468`, `:491`, `:497`) `/api/health` is deliberately **not** a proxy: > *"Orchestrator liveness. Reports its own configuration; never answers for the Codespace (that is > /api/capabilities)."* (`deploy/render/main.py:446-447`) and the design reason for keeping the two healths apart is recorded in the other gateway implementation, `gateway/app.py`: > *"Separate from `/v1/health` on purpose: conflating them would make a gateway that is up but whose > upstream is down indistinguishable from a gateway that is itself broken. This route never touches > the Space, so it costs nothing."* (`gateway/app.py:243-247`) ### 4.2 No second copy of the capability table The orchestrator does **not** decide capabilities. `deploy/render/main.py` states this as a design rule: > *"There is deliberately **no second copy** of the capability table here; the gateway proxies > `/v1/capabilities` and nothing else decides that question."* (`deploy/render/main.py:28-29`) The handler confirms it: ```python @app.get("/api/capabilities") async def capabilities() -> JSONResponse: """Proxy ``GET /v1/capabilities`` — no local capability table.""" base, _ = await ensure_codespace_up() return await _proxy("GET", f"{base}/v1/capabilities") ``` (`deploy/render/main.py:491-495`) This is the orchestrator-side expression of the "single capability authority" ruling that `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3.1 records for the inference side. ### 4.3 The `x-satquery-transport` header — the measured proof of the path taken The **measured** transport value is `tunnel`. `docs/FINAL_DELIVERY_TODO.md` §6 E-03 records the verification: > *"`E-03` | P3-T01 | `curl …/api/capabilities`, `POST /api/infer {}` | 200 (6× available:true); 422 > `invalid_request`, header `x-satquery-transport: tunnel` | VERIFIED"* and §1.4: > *"Tunnel up / warm | **VERIFIED** | health `tunnel.agent_connected:true`; `/api/infer` returns > header `x-satquery-transport: tunnel`"* (`docs/FINAL_DELIVERY_TODO.md` §1.4) The client reads it deliberately, before the response object is discarded: ```js /* Read the transport/state headers BEFORE parsing: they are evidence about WHICH path served the request, and they are gone once the response object is discarded. `x-satquery-transport: tunnel` is the proof that Render forwarded to the Codespace rather than answering locally. */ var state = resp.headers.get('X-SatQuery-State') || ''; var transport = resp.headers.get('x-satquery-transport') || ''; ``` (`frontend/assets/js/live.js:311-317`) | Header | Values | Meaning | Source | |---|---|---|---| | `x-satquery-transport` | `tunnel` (measured) | the request was forwarded to the Codespace rather than answered locally | `docs/FINAL_DELIVERY_TODO.md` §6 E-03; `frontend/assets/js/live.js:315` | | `X-SatQuery-State` | `waking` \| `ready` | whether a cold start occurred | `deploy/render/main.py:488` | `X-SatQuery-State` is set on the `/api/infer` path in the monorepo copy: ```python out.headers["X-SatQuery-State"] = "waking" if woke else "ready" ``` (`deploy/render/main.py:488`) > **The tunnel header is not set by the monorepo copy.** `deploy/render/main.py` is **532 lines with > no tunnel code at all**; the deployed `SatQuery-Backend/main.py` is **768–769 lines with it** > (`docs/FINAL_DELIVERY_TODO.md` §1.1; `DELIVERY_REPORT_2026-09-25.md` §4). The monorepo copy > therefore documents the *contract* of the route, while the `tunnel` value is a **measured live > fact** recorded in the delivery evidence. See §15 for the consequence. `docs/DEPLOYMENT_TOPOLOGY.md` §2 states the measured transport shape in full: > *"**Measured 2026-09-25 (live).** Transport is an **outbound tunnel**, not a polled forwarded port: > the Codespace runs `deploy/codespace/tunnel_agent.py`, which dials out to `POST /tunnel/agent` > (long-poll) and executes against `http://127.0.0.1:8000` locally. When the Codespace is stopped the > agent stops polling → `GET /api/health` reports `tunnel.agent_connected:false` and `POST /api/infer` > parks until `SATQUERY_TUNNEL_TIMEOUT_S` (150 s), then returns `tunnel_offline` (503, > `recoverable:true`)."* ### 4.4 The gateway's own route allowlists The *deployed* gateway's allowlists live in `SatQuery-Backend/main.py`, but the monorepo copy in `gateway/app.py` declares the same three tuples and their reasoning: ```python PROXIED_ROUTES: tuple[str, ...] = ( "/v1/health", "/v1/capabilities", "/v1/analyze", "/v1/assets", ) BLOCKED_ROUTES: tuple[str, ...] = () COSTLY_ROUTES: tuple[str, ...] = ("/v1/analyze", "/v1/assets") ``` (`gateway/app.py:170-199`) `BLOCKED_ROUTES` is **empty, and should stay that way**: > *"EMPTY, and it should stay that way: this tuple exists so a route the contract discusses but the > server does not implement answers *501 with a reason* instead of a 404 that a frontend developer > would debug as a typo. Nothing is in that state right now."* (`gateway/app.py:177-184`) > *"An allowlist, not a passthrough: a gateway that forwarded arbitrary paths would expose every route > the Space happens to serve, including ones the contract does not document."* > (`gateway/app.py:161-163`) --- ## 5. `GET /v1/health` — the cheap liveness probe ### 5.1 Shape The response shape is `HealthStatus` (`core/schemas.py:430`): ```python class HealthStatus(BaseModel): model_config = ConfigDict(extra="forbid") status: Literal["ok", "degraded", "error"] = "ok" schema_version: str = SCHEMA_VERSION models: dict[str, str] = Field(default_factory=dict) device: str | None = None gpu_available: bool = False ``` ### 5.2 A real measured response `docs/API_CONTRACT.md` §2.1 publishes the **measured** output of a deployment where the CROMA checkpoint is not shipped: ```json { "status": "degraded", "schema_version": "1.0", "models": { "caption": "not_requested", "change": "not_requested", "change_vqa": "not_requested", "grounding": "not_requested", "optical_sar": "absent", "vqa": "not_requested" }, "device": "cpu", "gpu_available": false } ``` (`docs/API_CONTRACT.md` §2.1) ### 5.3 Field by field | Field | Type | Notes (verbatim where quoted) | |---|---|---| | `status` | `"ok" \| "degraded" \| "error"` | `degraded` = the service is up but at least one capability is not servable. **Derived, not asserted**: any `absent` capability makes the service `degraded`; any `unavailable` makes it `error` | | `schema_version` | `string` | Always present; `"1.0"` (`core/schemas.py:21`) | | `models` | `object` | Per-capability state. Values are **strings, not booleans**, so a reason can be carried | | `device` | `string \| null` | `"cpu"`, `"cuda"`, `"mps"`, or `null` if unknown | | `gpu_available` | `boolean` | Whether a CUDA/MPS device was detected | **Every capability the registry resolves appears in `models`**, and the set is identical to `capabilities[].task`: > *"the two endpoints are generated from one source, so they cannot enumerate different capabilities. > A key is never absent; a capability that cannot be served is reported with a state, not by > omission."* (`docs/API_CONTRACT.md` §2.1) The implementation asserts this rather than trusting it: ```python payload = health_payload() # Asserted rather than trusted: `HealthStatus` is `extra="forbid"`, so a # key added to the payload without a key added to the model would make # the response invalid against the project's own contract. Finding H-1 # was exactly this failure in the other direction. from core.schemas import HealthStatus HealthStatus.model_validate(payload) return JSONResponse(payload) ``` (`app/space_app.py:537-547`) ### 5.4 `device` is a closed set, and `null` means the value was not understood This is F-8, and it is worth restating because the failure mode is a false statement about the deployment: > *"This row has always published four legal values, but the reader accepted **any** string and echoed > it into the field, so `SATQUERY_DEVICE=garbage` served `{"device": "garbage"}` — a value the > frontend has no rendering for. The reader now casefolds and validates against the set above; > anything unrecognised is served as `null`. `null` is deliberately **not** a silent `"cpu"`: > reporting the CPU because the operator mistyped would be a false statement about the deployment, > and it is the same mistake that F-7 fixed in a different variable."* (`docs/API_CONTRACT.md` §2.1) The invariant the frontend may rely on: > *"**if `device == "cuda"` then `gpu_available` is `true`.** The converse does **not** hold — a GPU > may exist while `device` is `"cpu"` (the operator chose it, or the config did)."* > (`docs/API_CONTRACT.md` §2.1) ### 5.5 `gpu_available: false` is expected, not a fault > *"**Important for the frontend:** `gpu_available: false` on a ZeroGPU Space is **expected**, not an > error. ZeroGPU allocates the GPU only for the duration of a decorated call. Do not surface this as > a fault."* (`docs/API_CONTRACT.md` §2.1) On the **active** topology the declared device is `cpu` outright (`render.yaml:20-21`, `docs/DEPLOYMENT_TOPOLOGY.md` header), so `device: "cpu"` / `gpu_available: false` is the expected warm state rather than a transient one. --- ## 6. `GET /v1/capabilities` — what this deployment can do right now ### 6.1 The rule > *"What the deployment can do **right now**, derived from actual artifact presence — not from what > the code could theoretically do."* (`docs/API_CONTRACT.md` §2.2) > *"**Every capability the registry resolves is listed**, including ones this deployment cannot serve. > A capability that cannot be served is reported `available: false` with a reason, never omitted: > omitting it would make it invisible to the frontend, which cannot disable an affordance it was never > told about."* (`docs/API_CONTRACT.md` §2.2) ### 6.2 A real measured response `docs/API_CONTRACT.md` §2.2 publishes the measured output where four of the six capabilities lack runtime dependencies and `optical_sar` lacks its CROMA checkpoint: ```json { "schema_version": "1.0", "capabilities": [ { "task": "change", "available": true, "reason": null, "requires_pair": true, "max_assets": 2 }, { "task": "change_vqa", "available": true, "reason": null, "requires_pair": true, "max_assets": 2 }, { "task": "optical_sar", "available": false, "reason": "the CROMA backbone checkpoint (CROMA_base.pt) is not present in this deployment; without it optical/SAR fusion degrades to sensor-only; the trained optical/SAR fusion head is not present in this deployment; without it no fused prediction is produced", "requires_pair": true, "max_assets": 2, "modalities": ["optical", "sar"] }, { "task": "caption", "available": true, "reason": "the SmolVLM weights are fetched from the Hugging Face Hub on first use and no local checkpoint_path is configured in this deployment", "requires_pair": false, "max_assets": 1 } ], "deployment": { "platform": "huggingface-spaces", "zerogpu": true, "lazy_load": true, "cache_max_models": 1, "torch_compile": false } } ``` (`docs/API_CONTRACT.md` §2.2 — abridged: the real response lists all six) > *"note that `optical_sar`'s names **two** missing artifacts, because two are required and both are > absent."* (`docs/API_CONTRACT.md` §2.2) ### 6.3 Field by field | Field | Type | Notes | |---|---|---| | `capabilities[].task` | `string` | One of the `Task` enum values | | `capabilities[].available` | `boolean` | Whether the task can be served **right now** | | `capabilities[].reason` | `string \| null` | **Required when `available` is `false`.** *"A bare `false` with no reason is not compliant"* | | `capabilities[].modalities` | `string[]` | **Optional; present only for `optical_sar`** | | `capabilities[].requires_pair` | `boolean` | Whether two assets are required | | `capabilities[].max_assets` | `integer` | Maximum assets accepted | | `deployment.platform` | `string` | Deployment target, e.g. `"huggingface-spaces"` | | `deployment.zerogpu` | `boolean` | Whether GPU work runs under ZeroGPU's per-call allocation | | `deployment.lazy_load` | `boolean` | `true` means models load on first use | | `deployment.cache_max_models` | `integer` | Resident-model cap. `1` means requests serialize | | `deployment.torch_compile` | `boolean` | Always `false` | `torch_compile` carries a specific obligation: > *"Always `false`. `torch.compile` is unsupported on ZeroGPU and the config loader hard-fails on > `true` (finding C-8). Echoed here so an operator can confirm the constraint from a single > response."* (`docs/API_CONTRACT.md` §2.2) ### 6.4 A `reason` on an *available* capability is not a defect This is the single most likely misreading of the endpoint, and the contract calls it out twice: > *"**A `reason` on an *available* capability is not a defect.** Three capabilities above are > `available: true` and still carry a reason — it reads *"…fetched from the Hub on first use, no local > checkpoint configured"*. That is not an error; it is a disclosure that the first request will be > slow and will need egress. A frontend that treats a non-null `reason` as a failure will mislay every > cold start."* (`docs/API_CONTRACT.md` §2.2) ### 6.5 The capability state vocabulary — five words, closed `models` in `/v1/health` uses a **five-word closed vocabulary**: | Value | Meaning | |---|---| | `"loaded"` | Resident and ready | | `"absent"` | The artifact is not present in this deployment. Permanent for this revision; not retryable | | `"unavailable"` | Present but could not be loaded (corrupt, incompatible, dependency missing). **This is a defect**, distinct from `absent` | | `"not_requested"` | Nothing has attempted to load it yet (normal with `lazy_load: true`) | | `"evicted"` | Was loaded, was unloaded to make room (`cache_max_models: 1`) | > *"`absent` and `unavailable` **must not be conflated** in the UI. Absent means "this build does not > ship it"; unavailable means "this build ships it and it is broken"."* (`docs/API_CONTRACT.md` §2.3) > *"**These five words are the complete permitted vocabulary.** They are the contract's vocabulary and > are **not** the registry's."* (`docs/API_CONTRACT.md` §2.3) ### 6.6 The translation layer, and what it must never leak The adapter derives the contract state from the registry's **declared spec table** and the **filesystem**; it does not read a live registry state. > *"`app/deployment.py` inspects the registry's declared **spec table** and the **filesystem** and > derives the contract state from what it finds. It never calls `build()`/`build_all()`, because > requirement 4 (`DEPLOYMENT_ARCHITECTURE.md` §3.3) forbids loading a model to answer a metadata > request. A live registry state is therefore *not observable* on this path, and the registry's word is > reconstructed from the contract state — not translated into it."* (`docs/API_CONTRACT.md` §2.3.1) The exhaustive table of what the adapter can produce: | Contract state | When it is emitted | `available` | Why | |---|---|---|---| | `not_requested` | All declared shipped artifacts are present, and nothing has attempted a load. **The normal healthy state under `lazy_load: true`** | `true` | Nothing is missing. Emitting `loaded` here would claim a model was resident, which cannot be known without loading one | | `absent` | A required shipped artifact is not on disk in this deployment | `false` | Nothing is broken; the deployment does not ship it | | `unavailable` | Construction was attempted in this process and failed (defect path only) | `false` | Present but broken — a genuine defect | | `evicted` | *(never emitted)* | — | A runtime model-cache fact. No static inspection can observe it | | `loaded` | *(never emitted)* | — | See above | (`docs/API_CONTRACT.md` §2.3.1) > **`available: true` and `models: "not_requested"` coexist by design, and that is not a > contradiction.** *"The two fields answer different questions: `available` is "can this deployment > serve this capability?" and `not_requested` is "has anything loaded it yet?". Under `lazy_load: > true` the healthy answer to the second is *no, not yet* — for every capability, including ones that > will work perfectly on the first request."* (`docs/API_CONTRACT.md` §2.3.1) The registry's own vocabulary is **internal** and is never served on any endpoint. ### 6.7 The contract obligations that fall on the client > *"- The frontend **MUST** build its UI affordances from this response, not from a hardcoded list. A > capability that is `available: false` must be shown as disabled **with its `reason` displayed** — > never hidden, never silently downgraded to a different task. > - The deployment block echoes `configs/deploy.yaml`. Note `cache_max_models: 1`: at most one model > is resident. Concurrent requests for different specialists will evict each other, so **the frontend > must not assume parallel throughput**."* (`docs/API_CONTRACT.md` §2.2) ### 6.8 The `deployment` block on the live deployment is stale metadata `docs/FINAL_DELIVERY_TODO.md` §1.7 item 6 records this as a known defect: > *"**`/api/capabilities` `deployment` block** claims `huggingface-spaces`/`zerogpu` — stale > metadata."* The block is generated from `configs/deploy.yaml`, which is **frozen paperwork** describing an HF Space + Gradio + ZeroGPU target that no longer matches the active Render + tunnel topology (`docs/DEPLOYMENT_TOPOLOGY.md` §3.4; `docs/DEPLOYMENT_DECISION.md` §4). The capability **list** is live and correct (six capabilities, `available: true`); the **deployment** block is not. This is recorded as a defect rather than smoothed over, because a reader who trusts `platform: "huggingface-spaces"` would look for a Space that does not exist. --- ## 7. `POST /v1/analyze` — the one endpoint that runs inference ### 7.1 The request shape — `AnalysisRequest` ```python class AnalysisRequest(BaseModel): model_config = ConfigDict(extra="forbid") assets: list[str] = Field(min_length=1) query: str force_task: Task | None = None run_id: str | None = None ``` (`core/schemas.py:412-418`) Four fields, and **`extra="forbid"`** — an unknown field is a `422`, not a silently ignored one. #### A real request ```json { "assets": ["asset_0", "asset_1"], "query": "How has the built-up area changed between these two dates?", "force_task": "change_vqa", "run_id": "9f2c1c0e-4a5b-4f5e-9a2c-1b3d4e5f6a7b" } ``` (`docs/API_CONTRACT.md` §2.4) #### Field by field | Field | Type | Required | Notes | |---|---|---|---| | `assets` | `string[]` | **yes** | **Minimum length 1.** Values are **asset handles returned by the upload step** (§7.6), not base64 and not URLs | | `query` | `string` | **yes** | Natural language. Empty string is permitted by the schema but will route to an `unsupported_query` error in practice | | `force_task` | `string \| null` | no | One of the `Task` values. Bypasses the intent router | | `run_id` | `string \| null` | no | Client-supplied correlation id. If omitted the server generates one. **The server always echoes a `run_id` in the response** | (`docs/API_CONTRACT.md` §2.4) #### `assets` min_length 1 is enforced at two layers The schema declares `Field(min_length=1)` (`core/schemas.py:415`), and the gateway refuses an empty array before it can cost a round trip: ```python assets = parsed.get("assets") if not isinstance(assets, list) or not assets: return None, ( 422, translate_error( "invalid_request", "`assets` must be a non-empty array of asset handles.", detail=f"assets={assets!r}", )[1], ) ``` (`gateway/policy.py:815-824`) The same function refuses a non-string entry, a missing/non-string `query`, an unknown `force_task` value, and a non-string `run_id` — all as `422 invalid_request`, all **before** the Space is called: ```python force_task = parsed.get("force_task") if force_task is not None: if not isinstance(force_task, str): ... # 422 allowed = _task_values() if allowed is not None and force_task not in allowed: ... # 422 `force_task` is not a recognised task ``` (`gateway/policy.py:857-880`) The `force_task` check was **missing** and its omission was not harmless: > *"This check was MISSING and the omission was not harmless: a body with `force_task: "nonsense"` was > forwarded to the Space, whose schema rejected it -- so the client received a 502/upstream error for > a defect entirely local to the request. That both mis-states the fault and spends a GPU-quota round > trip on a body the Space cannot accept."* (`gateway/policy.py:846-851`) #### `force_task` is derived from the `Task` enum, never hard-coded ```python def _task_values() -> frozenset[str] | None: try: from core.schemas import Task return frozenset(member.value for member in Task) except Exception: # pragma: no cover - only in a broken install return None ``` (`gateway/policy.py:896-919`) and the asymmetry between the field check and the enum check is deliberate: > *"An unknown-field check needs a field set, and the documented four field names are a short, stable > list that has not changed since the contract was written. The `Task` values are a different case: > they are the router's vocabulary and the schema is explicit that it may grow (`core/schemas.py`). A > stale copy here would silently reject a newly-added task at the gateway, before the Space could > accept it -- a gate that fails closed on valid input, which is worse than no gate."* > (`gateway/policy.py:900-909`) #### Unknown fields are rejected, and the correction matters `docs/API_CONTRACT.md` §1.1 records a corrected claim — the earlier text asserted forward compatibility on read, and **the code says otherwise**: > *"`ResultEnvelope`, `HealthStatus`, `AnalysisRequest` and every other contract-facing model in > `core/schemas.py` sets `extra="forbid"` — **with exactly one exception, `GeoMetadata`, which sets > `extra="allow"`**."* (`docs/API_CONTRACT.md` §1.1) `GeoMetadata` is the one open surface: ```python class GeoMetadata(BaseModel): model_config = ConfigDict(extra="allow") ``` (`core/schemas.py:120-121`) > *"It is the geospatial descriptor attached to `AssetMetadata.geo` and `SpecialistResult.geospatial`, > so it **is** reachable in every `/v1/analyze` response. The reason is that a raster reader supplies > whatever tags the source file carries, and forbidding unknown keys there would discard provenance a > caller may need."* (`docs/API_CONTRACT.md` §1.1) The corrected consequences, stated plainly: - **Additive changes are not free.** Adding a field to a response breaks any client that validates strictly. - **A version bump is required** when a field is added, not only when one is removed. - **Clients should be written permissively even though the server is strict** — a client-side robustness measure, not a server guarantee. ### 7.2 The response shape — `ResultEnvelope` ```python class ResultEnvelope(BaseModel): model_config = ConfigDict(extra="forbid") run_id: str result: SpecialistResult trace: ExecutionTrace schema_version: str = SCHEMA_VERSION ``` (`core/schemas.py:421-427`) ### 7.3 A real response `docs/API_CONTRACT.md` §2.4 publishes the measured shape: ```json { "run_id": "9f2c1c0e-4a5b-4f5e-9a2c-1b3d4e5f6a7b", "schema_version": "1.0", "result": { "task": "change_vqa", "answer": "The built-up area increased...", "labels": [], "regions": [], "boxes": [ { "x1": 0.12, "y1": 0.34, "x2": 0.56, "y2": 0.78, "label": "expanded built-up area", "score": 0.81, "coordinate_system": "normalized_0_1" } ], "masks": [], "change_map": null, "evidence": [ { "evidence_id": "ev_001", "type": "change_map", "score": 0.72, "source_specialist": "change_vqa", "coordinate_system": "normalized_0_1", "coordinates": [0.12, 0.34, 0.56, 0.78], "artifact_ref": null, "payload": {} } ], "confidence": { "raw": 0.991, "calibrated": 0.987, "method": "temperature_scaling", "components": {}, "degraded": false, "degradation_reason": null }, "geospatial": {}, "execution_trace": null, "schema_version": "1.0", "warnings": [], "degraded": false }, "trace": { "run_id": "9f2c1c0e-4a5b-4f5e-9a2c-1b3d4e5f6a7b", "task": "change_vqa", "intent": null, "query": "How has the built-up area changed?", "modalities": ["optical"], "workflow": [], "steps": [], "timings": {}, "selected_models": [], "parameters": {}, "config_hash": "78f1e3700da15aa1", "inputs": [], "outputs": [], "errors": [], "fallbacks": [], "contradiction": false, "validation": {}, "confidence": null, "started_at": "2026-09-22T04:12:21.000Z", "finished_at": "2026-09-22T04:12:29.400Z", "schema_version": "1.0" } } ``` (`docs/API_CONTRACT.md` §2.4) > *"`trace` above lists every field `ExecutionTrace` defines. Fields left `null` or empty here are > genuinely optional, not omitted from the contract — the model uses defaults, so they will normally > be **present** in a real response."* (`docs/API_CONTRACT.md` §2.4) ### 7.4 The fields a client must read correctly | Field | Why it matters | |---|---| | `result.confidence.value` | **NOT a JSON field.** It is a Python `@property` on `ConfidenceBreakdown` and is **not serialised**. Read `calibrated` if it is non-null, otherwise `raw` | | `result.confidence.method` | `"uncalibrated"` or `"temperature_scaling"` | | `result.confidence.degraded` / `degradation_reason` | Whether the confidence is trustworthy. **Display the reason verbatim when set** | | `result.degraded` + `result.warnings` | The result is served but something was degraded | | `result.answer` | `""` for non-VQA tasks. Empty is valid | | `result.boxes[].coordinate_system` | **Read this per box** | | `result.boxes[]` flat geometry | `x1, y1, x2, y2` are **flat fields on the box**, not a nested `box` object. `Region` is the one with a nested `box` | | `result.evidence[]` shape | Every item carries `evidence_id`, `type`, `score`, `source_specialist`, `coordinate_system`, `coordinates`, `artifact_ref`, `payload`. Note `score` — not `value` — and `source_specialist` — not `source`. **`artifact_ref` is always `null` in v1** | | `result.evidence[].type` | One of 11 `EvidenceType` values | | `trace.steps[].state` | `ControllerState` — the pipeline stage. `detail` and `duration_ms` accompany it | | `trace.steps` | Observable facts only. **Never chain-of-thought** (plan §26). Safe to display | | `trace.config_hash` | The frozen config identity. `78f1e3700da15aa1` for this revision | (`docs/API_CONTRACT.md` §2.4) The `value` property is real and private to Python: ```python @property def value(self) -> float: return self.calibrated if self.calibrated is not None else self.raw ``` (`core/schemas.py:271-273`) > *"**NOT a JSON field.** […] (verified: `model_dump()` yields only `calibrated, components, > degradation_reason, degraded, method, raw`)."* (`docs/API_CONTRACT.md` §2.4) The client implements the rule with `??`: ```js const shown = c.calibrated ?? c.raw; // NOT c.value — it is not serialised ``` (`docs/FRONTEND_INTEGRATION.md` §4.2) ### 7.5 The confidence contract `ConfidenceBreakdown` (`core/schemas.py:259-273`): | Field | Meaning | |---|---| | `raw` | The uncalibrated score | | `calibrated` | The post-calibration score, or `null` | | `method` | `"uncalibrated"` or `"temperature_scaling"` | | `components` | A `string -> float` map. May be empty. **Diagnostic only** — do not compute a confidence from it | | `degraded` | Whether this confidence should be trusted | | `degradation_reason` | Why, when `degraded` is `true` | **The four rules the frontend MUST follow:** > *"1. Display `calibrated` when it is not `null`; otherwise display `raw`. > 2. Display `method` next to the value. `temperature_scaling` means a fitted correction was applied; > `uncalibrated` means it was not. > 3. **Never present a confidence as a percentage without its method.** A raw 0.99 and a calibrated > 0.99 do not mean the same thing. > 4. When `degraded` is `true`, show `degradation_reason`. Confidence that is degraded is not a > quality signal."* (`docs/API_CONTRACT.md` §4) **The measured caveat, recorded honestly:** > *"The R-02 calibration fit (`artifacts/calibration_v001.json`, `T = 0.9772731820958189`, 16,441 Val > rows) found that the raw softmax was **already near-calibrated** (ECE 0.013755) and that temperature > scaling made ECE very slightly **worse** (0.014929) while improving NLL marginally (0.689741 → > 0.689631). The frontend must not imply that `temperature_scaling` is inherently "more accurate" than > `uncalibrated`."* (`docs/API_CONTRACT.md` §4) ### 7.6 Artifact refs are `null` in v1, and why > *"**Every `artifact_ref` and `change_map` in a v1 response is `null`.** This is a deliberate > contract, not a missing value."* (`docs/API_CONTRACT.md` §2.4) F-16 (owner ruling 2026-09-23): **never expose filesystem paths.** > *"The specialists *do* render their artifacts — the change map and the optical/SAR views are written > server-side — but their location is an operator fact, not a client-facing one. A response that > carried the server's path would disclose the deployment's directory layout to an unauthenticated > caller, and nothing the frontend can do requires it."* (`docs/API_CONTRACT.md` §2.4) > *"**No `artifact://` URI is fabricated in its place.** v1 has **no artifact-serving endpoint**, so a > URI would be a promise the service cannot keep — strictly worse than `null`, because the frontend > would build a link that 404s."* (`docs/API_CONTRACT.md` §2.4) What replaces the ref: | Removed | Replaced by | |---|---| | `change_map` path | `null`, plus the change statistics in the CHANGE_MAP evidence's `payload` (`total_change_pixels`, `n_components_kept`, `threshold`) | | view `artifact_ref` path | `null`, plus `payload.rendered` / `payload.retrievable` / `payload.retrieval` | | — | an explicit `warnings[]` entry saying the artifact is **NOT retrievable** | The schema carries the ruling in the field description: ```python artifact_ref: str | None = Field( default=None, description=( "Reference to an externally retrievable artifact. NEVER a " "filesystem path (F-16, owner ruling 2026-09-23): v1 exposes no " "artifact-serving endpoint, so this is null unless a deployment " "supplies a client-fetchable reference. An artifact may still be " "written server-side where configured; being written is not the " "same as being retrievable." ), ) ``` (`core/schemas.py:225-235`) ### 7.7 The handle → path translation happens in exactly one place The controller needs a path to inspect; the contract carries handles. The handler is the one place that translates: ``` handle -> AssetStore.get() -> path -> AnalysisRequest.assets ``` (`app/space_app.py:674`) ```python store = get_asset_store() try: handles = store.resolve_many(list(request.assets)) except UnknownAssetError as exc: ... # input_error: "One or more asset handles are unknown or have expired." # Rebuild the request with resolved PATHS. `model_copy` rather than # mutating, because `AnalysisRequest` is the contract's model and a # handler must not rewrite a validated request in place. request = request.model_copy( update={"assets": [str(handle.path) for handle in handles]} ) ``` (`app/space_app.py:709-724`) > *"A handle that is unknown or expired is refused HERE, with a named error, rather than being passed > to the controller as a path that does not exist -- which would surface as a raster read failure and > name the wrong cause."* (`app/space_app.py:676-678`) `resolve_many` is all-or-nothing: > *"All-or-nothing: a partial resolution would let an analysis start with one of a required pair > missing, which the specialists would then reject with a pairing error that names the wrong cause. > Failing here names the real one."* (`gateway/assets.py:400-406`) ### 7.8 The success status `/v1/analyze` returns `200` with the envelope. `deploy/render/main.py`'s `_proxy()` passes the upstream status through unchanged: > *"Connection/transport errors and non-JSON upstream bodies are translated into the v1 envelope > (`502`, `recoverable: true`); the upstream status is otherwise passed through unchanged."* > (`deploy/render/main.py:372-376`) --- ## 8. `POST /v1/assets` — the upload endpoint (Option A) ### 8.1 The request `multipart/form-data` with exactly one part, the file. The `Content-Type` of the part is the declared type (`docs/API_CONTRACT.md` §2.5). The shipped client sends **raw bytes**, not multipart, and says why: ```js /** * Upload ONE File and return its asset handle. * * The body is the raw bytes with the derived Content-Type -- not multipart. * `/v1/assets` reads the raw body (gateway/assets.py `read_body_bounded`), so * wrapping the file in a form would store the multipart wrapper as the image. */ ``` (`frontend/assets/js/live.js:190-196`) ```js return fetch(SQ.live.url('assets'), { method: 'POST', headers: { 'Content-Type': contentType }, body: file, signal: opts.signal }) ``` (`frontend/assets/js/live.js:215-220`) > **Note the divergence and do not smooth it over.** `docs/API_CONTRACT.md` §2.5 and > `docs/FRONTEND_INTEGRATION.md` §3.2 both describe the upload as `multipart/form-data` with a part > named `file`, and the integration guide even warns *"Do not set `Content-Type` manually."* The > **shipped** client (`live.js`) sends raw bytes with an explicit `Content-Type` derived from the file > extension. The Space's handler reads the raw body — `raw, too_large = await > read_body_bounded(request, _asset_max_file_bytes())` (`app/space_app.py:610`) — and takes the type > from the header — `store.put(raw, content_type=request.headers.get("content-type"))` > (`app/space_app.py:625-628`). Raw-body upload is what the deployed path exercises; the multipart > description in the two documents is not what the shipped client does. This is recorded rather than > resolved, because only the raw path has been run. The content type is derived from the extension on purpose: ```js /*: Sent explicitly rather than relying on `File.type`. Deliberate: a GeoTIFF arrives as `""` in Chrome and Firefox, and an empty Content-Type is rejected by the store. Deriving it from the extension means the client and the server agree on the same fact. */ ``` (`frontend/assets/js/live.js:75-78`) ### 8.2 The response `201` ```json { "asset_id": "asset_7c6f64a4a4c821e25d518467a1cc5d47", "content_type": "image/png", "bytes": 20481, "expires_at": "2026-09-22T04:42:21.000Z" } ``` (`docs/API_CONTRACT.md` §2.5) The handler returns exactly this shape: ```python return JSONResponse(status_code=201, content=handle.to_response()) ``` (`app/space_app.py:659`) and `to_response()` deliberately omits the path: ```python def to_response(self) -> dict[str, Any]: """The `POST /v1/assets` response body. `path` is deliberately absent. Returning it would hand a client a server-side filesystem location -- an information disclosure, and an invitation to construct a path directly instead of via a handle. """ return { "asset_id": self.asset_id, "content_type": self.content_type, "bytes": self.bytes, "expires_at": self.expires_at_iso, } ``` (`gateway/assets.py:219-231`) ### 8.3 Opacity — the handle *is* the access control > *"`asset_id` is `asset_` + 32 hex characters, from `secrets.token_hex(16)`. It is **128 bits of > entropy and carries no information about the upload** — no filename, no type, no index, no position. > There is no auth in v1 (§7), so this handle **is** the access control for the uploaded bytes"* > (`docs/API_CONTRACT.md` §2.5) ```python def _new_handle() -> str: """An opaque, unguessable handle. `secrets`, not `random`: the handle is this endpoint's only access control (`API_CONTRACT.md` section 7 -- there is no auth in v1). 128 bits from `token_hex(16)` is not brute-forceable, and the prefix keeps a handle recognisable in a log without making it derivable. """ return f"asset_{secrets.token_hex(16)}" ``` (`gateway/assets.py:466-474`) The stored filename is derived from the **content type**, never the client's filename: ```python #: Content type -> stored suffix. Derived from the TYPE, never from the #: client-supplied filename, so a hostile name cannot influence a path. _SUFFIXES: Mapping[str, str] = { "image/tiff": ".tif", "image/geotiff": ".tif", "image/png": ".png", "image/jpeg": ".jpg", "application/octet-stream": ".bin", } ``` (`gateway/assets.py:477-485`) ### 8.4 The three guarantees the frontend depends on | Concern | Guarantee | |---|---| | **Opacity** | 128 bits of entropy from `secrets.token_hex(16)`; no filename, type, index or position | | **Size limit** | A per-file byte cap, configurable per deployment. **It is enforced at two layers and a client should rely on both.** The *gateway* refuses an over-limit body from a declared `Content-Length` **and**, since the F-6 fix, while reading the bytes — so omitting the header does not evade it. The *Space* does the same since the F-9 fix, via the shared `gateway/assets.py::read_body_bounded`. An over-limit upload gets `413` and **writes nothing** | | **Content-type allowlist** | A **closed list of exactly five types**. A request with **no** declared type is **refused rather than defaulted**. A disallowed type gets `415`. Media-type parameters are ignored | | **Retries** | There is **no idempotency key**. A retry is a **new** upload that mints a **new** handle | (`docs/API_CONTRACT.md` §2.5) The five allowed types, declared identically on both layers: ```python #: The content types this endpoint accepts. Mirrors #: `GatewayConfig.allowed_content_types`; the Space's copy exists because the #: Space validates independently (defence in depth) rather than trusting that #: the gateway is the only caller. _ALLOWED_ASSET_CONTENT_TYPES: tuple[str, ...] = ( "image/tiff", "image/geotiff", "image/png", "image/jpeg", "application/octet-stream", ) ``` (`app/space_app.py:381-391`) and on the gateway side: ```python allowed_content_types: tuple[str, ...] = ( "image/tiff", "image/geotiff", "image/png", "image/jpeg", "application/octet-stream", ) ``` (`gateway/policy.py:229-236`) > *"**`image/tiff` is the type the geospatial specialists need** — a client that uploads only PNG/JPEG > can serve the VQA, caption and grounding tasks but not the change or optical/SAR ones."* > (`docs/API_CONTRACT.md` §2.5) An absent type normalises to the empty string so the allowlist refuses it: ```python def _normalise_content_type(content_type: str | None) -> str: """Lower-case the type and strip parameters, or '' when absent. `image/tiff; charset=binary` is a legitimate header and the parameter is not part of the type. `None` becomes `''` so the allowlist check refuses it -- defaulting an absent type to `application/octet-stream` would make the allowlist unenforceable for exactly the clients that omit the header. """ if not content_type: return "" return content_type.split(";", 1)[0].strip().lower() ``` (`gateway/assets.py:488-498`) A zero-byte upload is refused before the raster reader ever sees it: ```python if not data: # A zero-byte upload cannot be a raster. Refused here rather than # downstream so the failure names the upload, not the reader. raise AssetTooLargeError("the uploaded file is empty") ``` (`gateway/assets.py:313-316`) ### 8.5 Errors on this endpoint `413` over the size limit · `415` unsupported or absent content type · `503` the asset store is not configured on this deployment · `400` for a malformed body. All use the §9 envelope (`docs/API_CONTRACT.md` §2.5). The handler maps each store error onto an **existing** taxonomy code: ```python except AssetTooLargeError as exc: status, body = translate_error("oversized_image", "The uploaded file is too large.", detail=exc.detail) except UnsupportedContentTypeError as exc: status, body = translate_error("raster_read_error", "This file type is not accepted.", detail=exc.detail) except AssetStoreFullError as exc: status, body = translate_error("model_unavailable", "The upload buffer is full. Please wait and retry.", detail=exc.detail, recoverable=True) except AssetStoreError as exc: # pragma: no cover - defensive status, body = translate_error("input_error", "The upload could not be stored.", detail=exc.detail) ``` (`app/space_app.py:629-657`) `gateway/assets.py` explains why the store's own error family is **not** part of `core/errors.py`: > *"Deliberately NOT a `core.errors.SatQueryError`: `core/errors.py` is the taxonomy the contract > publishes (`API_CONTRACT.md` section 5.2, 23 codes), and none of those codes means "this handle is > unknown". Adding one would move the taxonomy, which the contract forbids the gateway from doing > (`docs/DEPLOYMENT_ARCHITECTURE.md` section 2.3). The route therefore maps these onto *existing* > codes, and says which."* (`gateway/assets.py:156-163`) > **The two layers do not map identically.** The Space maps `UnsupportedContentTypeError` to > `raster_read_error`, while `docs/FRONTEND_INTEGRATION.md` §3.3's table names `unsupported_bands` for > the `415` case. The code is authoritative: the shipped handler emits `raster_read_error` > (`app/space_app.py:638`). The integration guide's table is a client-facing approximation and > disagrees with the code on this one row. ### 8.6 When upload is disabled The endpoint fails closed. `_asset_store_available()` requires **both** variables: ```python def _asset_store_available() -> bool: """Whether the upload endpoint is enabled. Off by default in a deployment that has not set `SATQUERY_ASSET_DIR`, and ON when it has -- so turning on the fourth endpoint is an explicit operator action rather than something that starts writing to a temp directory unbidden. `/v1/capabilities` is where a client learns which it is. """ import os return bool(os.environ.get("SATQUERY_ASSET_ENABLED", "")) and bool( os.environ.get("SATQUERY_ASSET_DIR") ) ``` (`app/space_app.py:394-406`) ```python if not _asset_store_available(): # A deployment that has not enabled uploads says so, with the reason # and the switch, rather than accepting bytes it cannot keep. status, body = translate_error( "model_unavailable", "Asset upload is not enabled on this deployment.", detail=( "Set SATQUERY_ASSET_ENABLED=1 and SATQUERY_ASSET_DIR to a " "writable path to enable POST /v1/assets. See " "docs/DEPLOYMENT_ARCHITECTURE.md." ), recoverable=False, ) ``` (`app/space_app.py:578-591`) `docs/DEPLOYMENT_TOPOLOGY.md` §3.3 states the same requirement in the env-var table: *"Both required for `/v1/assets`; fails closed (503) otherwise"*. ### 8.7 Lifetime, capacity, and the refusal-not-eviction rule > *"Handles expire on a TTL and are **refused on read** once lapsed — a lapsed handle is rejected even > if nothing has swept it, so a client never succeeds by racing a cleanup job. Capacity is bounded, and > **a live handle is never evicted to make room**: when the store is full it refuses (`503`) rather > than invalidating a handle a client is about to use. A handle is single-use in practice — consuming > it in `/v1/analyze` does not consume it, so the same handle may be analysed repeatedly until it > expires."* (`docs/API_CONTRACT.md` §2.5) Expiry is checked on read: ```python def get(self, asset_id: str, *, now: float | None = None) -> AssetHandle: """Resolve a handle, or raise `UnknownAssetError`. Expiry is evaluated here rather than trusted to a sweeper: a handle past its deadline is unknown even if nothing has run `sweep()`. That is what makes the TTL a guarantee instead of a housekeeping hope. """ ``` (`gateway/assets.py:370-376`) `UnknownAssetError` deliberately merges *expired* and *never issued*: > *"The two are one error on purpose: a client cannot act on the difference (both mean "upload > again"), and distinguishing them would report whether a handle had ever existed -- a small > information leak about other clients' uploads, which matters precisely because there is no auth."* > (`gateway/assets.py:183-189`) Sizing defaults, and the env-var overrides: | Knob | Default | Env var | Source | |---|---|---|---| | Per-file cap | `4 * 1024 * 1024` (4 MiB) | `SATQUERY_MAX_FILE_BYTES` | `app/space_app.py:359`; `gateway/policy.py:324` | | Capacity (handles) | `32` | `SATQUERY_ASSET_MAX_FILES` | `app/space_app.py:278` | | TTL | `900.0` s (15 min) | `SATQUERY_ASSET_TTL_S` | `app/space_app.py:298` | | Root | `tempfile.gettempdir()/satquery-assets` | `SATQUERY_ASSET_DIR` | `app/space_app.py:310` | | Enable switch | off | `SATQUERY_ASSET_ENABLED` | `app/space_app.py:404` | > **The measured per-file limit is 4,194,304 bytes.** `HANDOFF_NEXT_AGENT.md` §4 (session workspace) > records it as a hard constraint: *"Per-file upload limit 4,194,304 bytes (HTTP 413 above it)."* The > default in code is `4 * 1024 * 1024` = 4,194,304, so the default and the measured value agree. The capacity default was hardcoded until the STEP 8 audit: > *"These two were hardcoded until the STEP 8 audit recorded the resulting scaling limit: a deployment > with ample `SATQUERY_ASSET_DIR` and a burst of concurrent users could hit the ceiling before the TTL > reaped anything and answer `503` with disk free. That is a *refusal*, not corruption -- the store > never evicts a live handle -- but it is a limit an operator should be able to raise without editing > code."* (`app/space_app.py:254-261`) `stats()` was **removed** rather than left wired: > *"F-11 (owner ruling 2026-09-23): `stats()` lived here. It reported > `live`/`capacity`/`ttl_seconds`/`max_file_bytes`/`sweeps`/`capacity_refusals` for an operator, but > **no production module ever called it** -- `docs/DEPLOYMENT_ARCHITECTURE.md` section 5 pointed an > operator at an instrument the deployment did not expose."* (`gateway/assets.py:449-457`) --- ## 9. Size and limit rules ### 9.1 Two caps, both enforced twice | Cap | Default | Where declared | Enforced at | |---|---|---|---| | Whole-request body | `8 * 1024 * 1024` (8 MiB) | `GatewayConfig.max_body_bytes` (`gateway/policy.py:219`) | gateway: `Content-Length` check (admit step 3) **and** while reading (`_read_body_bounded`) | | Per-file upload | `4 * 1024 * 1024` (4 MiB) | `GatewayConfig.max_file_bytes` (`gateway/policy.py:221`) and `_asset_max_file_bytes()` (`app/space_app.py:359`) | gateway **and** Space, both while reading | The invariant that ties them together: ```python if self.max_file_bytes > self.max_body_bytes: raise ValueError( "max_file_bytes exceeds max_body_bytes; the per-file cap would " "be unreachable and the body check would fire first" ) ``` (`gateway/policy.py:273-277`) ### 9.2 The `Content-Length` check is declarative; the read-time check is not The admit ladder's step 3: ```python # 3. Body size, BEFORE the body is read. This is the check that saves # quota and memory; everything downstream has already buffered it. if content_length is not None and content_length > self.config.max_body_bytes: status, body = translate_error( "oversized_image", "The request body is too large.", detail=( f"Content-Length {content_length} exceeds the " f"{self.config.max_body_bytes} byte limit" ), request_id=request_id, ) return PolicyDecision.refused(request_id, status, body, headers) ``` (`gateway/policy.py:606-618`) F-6's measurement is the reason a second, read-time check exists: > *"Measured through this stack on 2026-09-22 with `max_body_bytes` at 8 MiB: > > ``` > Content-Length declared, 12 MiB -> 413 oversized_image, peak 0.2 MiB, > 0 bytes read (the cap worked) > Content-Length omitted, 12 MiB -> 502 model_unavailable, peak 13.9 MiB, > 12 MiB read (the cap was SKIPPED) > ``` > > and allocation tracked the body size exactly with no ceiling -- 1/8/16/32/64 MiB in produced > 3.0/8.1/16.0/32.0/64.0 MiB allocated. So the header check protects the common case and bounds nothing > in the hostile one."* (`gateway/app.py:460-478`) The read-time cap is unconditional and shared by both layers: ```python async def read_body_bounded(request: Any, limit: int) -> tuple[bytes, bool]: """Read a request body, refusing it the moment it exceeds `limit`. ... The `Content-Length` header is deliberately NOT consulted here, not even as a fast path. The F-6 measurement is the reason: a declared length drew `413` with 0.2 MiB peak, but an **omitted** one drew `502` with a 13.9 MiB peak, so anything keyed on that header holds only for clients that tell the truth. """ chunks: list[bytes] = [] total = 0 async for chunk in request.stream(): total += len(chunk) if total > limit: return b"", True chunks.append(chunk) return b"".join(chunks), False ``` (`gateway/assets.py:100-148`) The F-9 measurement on the **Space** side: > *"Measured 2026-09-22 with the cap at 1 MiB: a 64 MiB body produced a peak allocation of **128 MiB** > and a 16 MiB body 32 MiB, tracking body size linearly with no ceiling, and the `413` came only after > everything had been held."* (`app/space_app.py:595-598`) ### 9.3 The F-7 correction — one variable, and it must mean one value `SATQUERY_MAX_FILE_BYTES` is read by **both** layers, and until F-7 each layer parsed it separately: > *"Measured on four inputs (probe `probe_f7_cap_parsers.py`): `'abc'` and `'4e6'` made the gateway > **raise at startup** while the Space **silently returned the 4 MiB default**; `'0'` and `'-1'` were > **accepted** by the gateway while the Space rejected them only when the first upload arrived. > Neither layer was right in both directions. Both now refuse an unparsable **or non-positive** value, > naming the variable, and a cross-layer agreement test drives the whole matrix through both real > parsers."* (`docs/API_CONTRACT.md` §2.5) The Space's reader now refuses rather than defaulting: ```python try: value = int(raw) except ValueError: raise ValueError( f"SATQUERY_MAX_FILE_BYTES={raw!r} is not an integer. It is NOT " f"defaulted, because the gateway refuses this same value at startup " f"and silently substituting a different cap here would leave the two " f"layers disagreeing about what 'too large' means -- the exact " f"failure this shared variable exists to prevent." ) from None if value <= 0: raise ValueError(...) ``` (`app/space_app.py:360-378`) The gateway refuses it at startup: ```python max_file_bytes = _int("SATQUERY_MAX_FILE_BYTES", 4 * 1024 * 1024) if max_file_bytes <= 0: raise ValueError( f"SATQUERY_MAX_FILE_BYTES={max_file_bytes} is not positive. A " f"non-positive per-file cap would make every upload fail on the " f"Space while the gateway kept admitting it; the Space's AssetStore " f"rejects the same value, so it is refused here to fail at startup " f"with the variable named rather than on the first upload" ) ``` (`gateway/policy.py:324-332`) > *"A deployment whose cap is malformed no longer starts at all, at either layer, instead of quietly > running on a limit nobody chose."* (`docs/API_CONTRACT.md` §2.5) ### 9.4 The pixel budget is a different limit, owned by the planner The per-file byte cap is not the image-size limit. `core/planner.py` owns a **pixel budget** of 25,000,000, and exceeding it is `oversized_image` (`recoverable: True` — retry at reduced resolution): ```python class OversizedImageError(InputError): code = "oversized_image" user_message = "The image exceeds the configured pixel budget." # Recoverable via downscale. ``` (`core/errors.py:147-154`) `HANDOFF_NEXT_AGENT.md` §8 item 6 and the release chapter 03 §25 record the value as 25,000,000; this chapter does not restate the planner's rules (see [03 — Request Lifecycle](./03-request-lifecycle.md) §25). ### 9.5 Rate limiting — fairness, not protection ```python #: Per-IP request budget for `POST /v1/analyze`. rate_limit_per_ip: int = 10 #: Window for the per-IP budget, in seconds. rate_limit_window_s: float = 60.0 ``` (`gateway/policy.py:222-225`) The limiter is **in-memory** and **fixed-window**: > *"A distributed limiter needs shared state, and the plan forbids Redis-cluster infrastructure > (`docs/DEPLOYMENT_ARCHITECTURE.md` section 6). An in-memory limiter in a single-instance Railway > service loses its counters on restart -- which is correct behaviour for protecting a *daily* GPU > budget, because the thing being protected (the Space's quota) is unaffected by a gateway restart. > The limiter therefore protects the budget, not a billing invariant."* (`gateway/policy.py:362-373`) A denied request does **not** increment the counter: ```python if state.count >= self.limit: retry_after = self.window_s - (now - state.window_start) return False, 0, max(0.0, retry_after) ``` (`gateway/policy.py:396-398`) > *"A denied request does **not** increment the counter -- otherwise a client hammering the endpoint > would push its own reset further away on every rejected attempt."* (`gateway/policy.py:386-389`) The client identity is best-effort, and the contract says so: > *"`X-Forwarded-For` is used because the gateway sits behind Railway's proxy, but it is > attacker-controlled absent a trusted proxy, so this is a best-effort guard, not a security boundary. > The runbook says so."* (`gateway/policy.py:408-413`) and the limiter bounds request **count**, not request **size**: > *"It bounds request COUNT (10/60s, measured engaging at exactly 10), not request SIZE -- ten admitted > 16 MiB requests are 160 MiB of unaccounted memory. That is the F-5 distinction again: a fairness > control is not a protection control."* (`gateway/app.py:475-478`) **Rate-limit values are not specified by the plan** (`docs/API_CONTRACT.md` §8), so the numbers above are this implementation's defaults, not contract constants. --- ## 10. CORS — an explicit allowlist, never `*` ### 10.1 The rule > *"The gateway sets CORS explicitly to the deployed frontend origin. It does not use a wildcard. A > preflight `OPTIONS` is answered by the gateway, not by the HF Space."* (`docs/API_CONTRACT.md` §7.1) The configuration refuses to start rather than guess: ```python if not self.allowed_origins: raise ValueError( "allowed_origins must be non-empty. An empty allowlist would " "either block every browser or, if the app fell back to '*', " "expose the Space. Refuse to start rather than guess." ) if "*" in self.allowed_origins: raise ValueError( "allowed_origins must not contain '*'. A wildcard exposes the " "Space to any origin (docs/DEPLOYMENT_ARCHITECTURE.md 2.1)" ) ``` (`gateway/policy.py:248-258`) ### 10.2 A disallowed origin receives no CORS headers at all ```python def build_cors_headers( origin: str | None, allowed: Sequence[str], *, request_headers: str = "" ) -> dict[str, str]: """Explicit CORS headers, or none. ... A disallowed origin receives **no CORS headers at all**, which is what makes the browser block the response. Echoing the origin back with `Access-Control-Allow-Origin: ` regardless would defeat the allowlist entirely. """ if not origin or origin not in allowed: return {} return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": request_headers or "Content-Type, X-Request-Id", "Access-Control-Max-Age": "600", "Vary": "Origin", } ``` (`gateway/policy.py:429-454`) `docs/DEPLOYMENT_TOPOLOGY.md` §3.2 lists the rule among the gateway's responsibilities: *"CORS allowlist (never `*`)"*. ### 10.3 Preflight is answered by the gateway, never forwarded ```python # 1. Preflight is answered here, never forwarded. The Space has no CORS # configuration and forwarding OPTIONS would waste a round trip. if method == "OPTIONS": return PolicyDecision.allowed(request_id, headers) ``` (`gateway/policy.py:589-592`) ### 10.4 F-2 — the response leg strips every upstream CORS header This is a **security** rule, not hygiene, and the bypass was measured: > *"The bypass was measured, not theorised. With the allowlist set to > `["https://app.example.com"]` and the Space answering with > `Access-Control-Allow-Origin: *`: > > * `GET /v1/health` from `https://evil.example.net` returned **two** values for > `access-control-allow-origin`, `*` and the allowlisted origin; > * `POST /v1/analyze` from the same disallowed origin returned `200` with > `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Credentials: true`. > > Both are the failure `docs/DEPLOYMENT_ARCHITECTURE.md` §2.1 exists to prevent: the allowlist is > bypassed by a header the gateway never inspected."* (`gateway/policy.py:705-723`) The fix is a prefix filter on the response leg: ```python return { key: value for key, value in upstream.items() if key.lower() not in DEFAULT_HOP_BY_HOP and key.lower() not in blocked # F-2. A prefix rule, not an enum: RFC 6648 discourages new `Access-` # headers, but the CORS family has grown (`-Allow-Credentials`, # `-Expose-Headers`, `-Max-Age`, `-Allow-Methods`, `-Allow-Headers`) # and an enum would silently miss whichever is added next. and not key.lower().startswith("access-control-") } ``` (`gateway/policy.py:741-753`) and `_proxy()` pins the filter with an assertion rather than trusting it: ```python assert not any(_is_cors_header(k) for k in out_headers) or decision.headers, ( "a CORS header reached the response without a policy decision; the " "upstream's headers are no longer filtered (see F-2)" ) out_headers.update(decision.headers) ``` (`gateway/app.py:592-596`) ### 10.5 The orchestrator's own allowlist assembly `deploy/render/main.py` assembles the list from three sources and re-checks for a wildcard, because `CORSMiddleware` does not run `GatewayConfig.__post_init__`: ```python raw = os.environ.get("SATQUERY_ALLOWED_ORIGINS", "") origins: list[str] = [o.strip() for o in raw.split(",") if o.strip()] ... origins.extend(_PRODUCTION_ORIGINS) if include_dev: origins.extend(_DEV_ORIGINS) if "*" in origins: raise ValueError( "SATQUERY_ALLOWED_ORIGINS must not contain '*'. A wildcard exposes " "the deployment to any origin (docs/DEPLOYMENT_ARCHITECTURE.md 2.1)." ) ``` (`deploy/render/main.py:194-208`) The production origin is hard-coded so an env-var typo cannot take the site down: ```python #: The production frontend origin. Listed here rather than only in the #: environment so that a deployment which forgets `SATQUERY_ALLOWED_ORIGINS` #: still serves the real frontend -- an empty allowlist would otherwise take the #: live site down, which is a worse failure than the one this guards. _PRODUCTION_ORIGINS: tuple[str, ...] = ("https://satquery.pages.dev",) ``` (`deploy/render/main.py:145-149`) The dev origins are **enumerated host:port pairs**, never a regex or a suffix match: ```python _DEV_ORIGINS: tuple[str, ...] = tuple( f"http://{host}:{port}" for host in ("localhost", "127.0.0.1") for port in ("3000", "5500", "5173", "8000", "8080") ) ``` (`deploy/render/main.py:139-143`) > *"This list is deliberately EXPLICIT, never a wildcard or a suffix match. It cannot be used to reach > the deployment from an arbitrary host: only a browser running on the developer's own machine can > send `Origin: http://localhost:*`."* (`deploy/render/main.py:135-138`) The measured live allowlist is the single production origin: `SATQUERY_ALLOWED_ORIGINS=https://satquery.pages.dev` (`docs/DEPLOYMENT_TOPOLOGY.md` header note). --- ## 11. The HARD RULE — the gateway must NOT retry `POST /api/infer` ### 11.1 The rule, stated three times in the sources > *"Render must not retry `POST /api/infer` on its own — a retry would consume inference a second time. > The client decides on retry. (Matches the gateway contract in `DEPLOYMENT_ARCHITECTURE.md` §2.2.)"* > (`docs/DEPLOYMENT_TOPOLOGY.md` §2) The code says it at the one place it could be violated — the transport-failure branch: ```python except Exception as exc: # network-level failure # NO RETRY. A retry on /v1/analyze would spend GPU quota twice # (docs/DEPLOYMENT_ARCHITECTURE.md section 2.2). ``` (`gateway/app.py:550-552`) and the frontend says it to the user: > *"**Never automatically retry `POST /v1/analyze`.** Each attempt consumes GPU quota, and on a > 5-minute daily budget an auto-retry loop can exhaust the day. Retries must be an explicit user > action."* (`docs/FRONTEND_INTEGRATION.md` §6.1) ### 11.2 Why: the retry is not free, and it is not the gateway's call Three independent reasons, each recorded: 1. **It costs inference twice.** A retry on `/v1/analyze` spends GPU quota a second time (`gateway/app.py:551`). 2. **The gateway cannot know whether the first attempt succeeded.** A transport failure is ambiguous: the upstream may have completed the work and failed to answer. Only the client holds the intent. 3. **The plan's boundary excludes it.** `docs/DEPLOYMENT_ARCHITECTURE.md` §2.2 is the "what the gateway must NOT do" list, and `docs/API_CONTRACT.md` §8 records that the rate-limit values are the gateway's to choose while the retry policy is not. ### 11.3 What the gateway does instead It classifies the failure and returns the envelope, with a stable `request_id` the client can quote: ```python _log.error( "upstream transport failure for request_id=%s: %s: %s", decision.request_id, type(exc).__name__, exc, exc_info=exc, ) status, err_body = translate_error( "model_unavailable", "The analysis service is not reachable.", detail=_transport_failure_detail(exc), recoverable=True, request_id=decision.request_id, ) return JSONResponse(status_code=502, content=err_body, headers=decision.headers) ``` (`gateway/app.py:565-579`) `recoverable: True` is the honest signal: *a retry may help, but the client decides.* ### 11.4 The client's side of the rule The shipped client offers no auto-retry. It surfaces which step failed and what the server said: > *"HONEST FAILURE. When the backend is unreachable, the caller is told which step failed and what the > server said. Nothing is synthesised to fill the gap -- no invented answer, no placeholder > confidence."* (`frontend/assets/js/live.js:24-26`) > *"`GET /v1/health` and `/capabilities` are cheap and may be polled."* > (`docs/FRONTEND_INTEGRATION.md` §6.1) So the asymmetry is: **polling the cheap endpoints is fine; retrying the costly one is a user decision.** --- ## 12. The error contract ### 12.1 The envelope > *"Every non-2xx response body has this shape:"* (`docs/API_CONTRACT.md` §5) ```json { "error": { "code": "pair_misaligned", "message": "The images are not sufficiently co-registered for spatial analysis.", "detail": "RMSE 4.21 px exceeds the 2.0 px budget", "recoverable": false, "request_id": "req_01H...", "run_id": "9f2c1c0e-..." } } ``` (`docs/API_CONTRACT.md` §5) > *"`code` is **stable** and comes from `core/errors.py`. `message` is operator-safe > (`SatQueryError.user_message`). `detail` is technical and may be absent."* (`docs/API_CONTRACT.md` §5) `translate_error` builds it: ```python body: dict[str, Any] = { "error": { "code": code if known else "satquery_error", "message": message or "An internal error occurred.", "detail": detail or None, "recoverable": bool(recoverable), "request_id": request_id, "run_id": run_id, } } return status, body ``` (`gateway/policy.py:161-171`) Note the two `or None` / `or "An internal error occurred."` defaults: `detail` is emitted as `null` rather than `""` when absent, and `message` can never be empty. ### 12.2 The code is passed through unchanged > *"The `code` is passed through **unchanged**. `docs/DEPLOYMENT_ARCHITECTURE.md` section 2.3 forbids > the gateway from inventing or remapping codes: the taxonomy in `core/errors.py` is the single source > of truth, and a gateway that renamed anything would make the frontend's error handling > unpredictable."* (`gateway/policy.py:136-139`) An **unrecognised** code is treated as `satquery_error` and mapped to `500` — never to a success status — and the original code is preserved in `detail` for diagnosis: ```python known = code in _CODE_STATUS status = _CODE_STATUS.get(code, 500) if not known: # Keep the original code visible in `detail` for diagnosis, but present # a code the contract defines. Swallowing it entirely would hide a real # defect behind a generic one. detail = f"unmapped error code {code!r}" + (f"; {detail}" if detail else "") ``` (`gateway/policy.py:153-159`) ### 12.3 The HTTP status mapping | Status | When | `recoverable` | |---|---|---| | `400` | Malformed JSON, missing required field, or a malformed upload body | `false` | | `404` | The path is not an endpoint at all (`routing_error`) | `false` | | `405` | The path exists but not for this method (`routing_error`). `GET /v1/assets` is the common case | `false` | | `413` | Upload exceeds the per-file size limit | `false` | | `415` | Upload's content type is absent or not on the allowlist | `false` | | `422` | Schema violation (unknown field with `extra="forbid"`, wrong enum value, `assets` empty) | `false` | | `429` | Rate limited. Honours `Retry-After` | `true` | | `500` | Unexpected internal failure | `false` | | `503` | A required model is `absent` or `unavailable`; or GPU quota exhausted; or the asset store is unconfigured or full | depends | | `504` | The specialist exceeded its budget (`specialist_timeout`) | `true` | (`docs/API_CONTRACT.md` §5.1) `413` and `415` are upload-only. A `415` is the expected answer to an upload with no declared `Content-Type` — the server refuses rather than guessing (`docs/API_CONTRACT.md` §5.1). ### 12.4 `404` and `405` carry the same envelope — through both layers > *"**`404` and `405` carry this same envelope**, which is worth stating because they are the two > statuses a proxy framework raises before any handler runs. A client should therefore not special-case > them: parse `error.code` as usual."* (`docs/API_CONTRACT.md` §5.1) This is F-3 on the gateway and F-12 / F-12b on the Space. Measured before the fix, direct to the Space: ``` GET /v1/whocares -> 404 {"detail":"Not Found"} GET /v1/assets -> 405 {"detail":"Method Not Allowed"} an unwrapped failure -> 500 text/plain, no envelope at all ``` (`app/space_app.py:448-451`) Both layers register a handler for Starlette's `HTTPException`: ```python @api.exception_handler(StarletteHTTPException) async def _contract_envelope_for_transport_errors( request: Request, exc: StarletteHTTPException ) -> JSONResponse: code = "routing_error" if exc.status_code < 500 else "satquery_error" status, body = translate_error( code, "This endpoint does not exist." if exc.status_code == 404 else str(exc.detail), detail=( f"{request.method} {request.url.path} -> {exc.status_code}; " f"see docs/API_CONTRACT.md sections 2 and 5" ), recoverable=False, ) return JSONResponse( status_code=exc.status_code, content=body, headers=getattr(exc, "headers", None), ) ``` (`app/space_app.py:465-491`; the gateway's copy is at `gateway/app.py:302-332`) > *"the status is taken from the EXCEPTION, not from the code -- exactly as the gateway handler does > it, so the two layers cannot drift."* (`app/space_app.py:454-457`) An unhandled failure also answers with the envelope, and the client gets a **fixed, operator-safe** message: ```python @api.exception_handler(Exception) async def _contract_envelope_for_unhandled_failures( request: Request, exc: Exception ) -> JSONResponse: """F-12b: an unwrapped failure answers with the envelope, not plain text. ... **F-15 is enforced here as well as in the handlers.** The client is told the code and a fixed, operator-safe message; the exception's own text is recorded SERVER-SIDE only. [...] A traceback in `detail` would disclose internal paths and library versions to an unauthenticated caller. """ _log.exception( "unhandled failure on %s %s", request.method, request.url.path ) status, body = translate_error( "satquery_error", "An internal error occurred.", detail="", recoverable=False, ) return JSONResponse(status_code=status, content=body) ``` (`app/space_app.py:493-519`) ### 12.5 A trailing slash is a `307`, and it is a real footgun > *"Starlette's default `redirect_slashes` behaviour applies: `GET /v1/analyze/` answers `307` with > `Location: http:///v1/analyze`. Two consequences a client must handle, both verified > against the running app on 2026-09-22: > > * The `Location` is built from the gateway's own host, **not** from the client's request URL, so a > redirect followed naively after a `POST` may not land where the caller expects. Do not rely on it. > * A `307` preserves the method and body, so a `POST /v1/analyze/` will re-send the body to > `/v1/analyze` — which is fine, but it is a second request against the rate limiter, and > `/v1/analyze` is a `COSTLY_ROUTE`."* (`docs/API_CONTRACT.md` §5.1) > *"**Use exact paths with no trailing slash.** This is also why the runbook's `SATQUERY_SPACE_URL` is > normalised with a trailing-slash strip."* (`docs/API_CONTRACT.md` §5.1) The same normalisation exists for the orchestrator's upstream URL, and it refuses rather than trims: ```python if self.upstream_url.endswith("/"): raise ValueError( "upstream_url must not have a trailing slash; a doubled slash " "produces a 404 from the Space that looks like an outage" ) ``` (`gateway/policy.py:239-243`) and the environment reader strips it so the operator cannot cause the failure: ```python upstream_url=env.get("SATQUERY_SPACE_URL", "").rstrip("/"), ``` (`gateway/policy.py:335`) ### 12.6 The complete `code` taxonomy — 23 codes From `core/errors.py`. The frontend should map these to user-facing copy; the `user_message` field is a safe default (`docs/API_CONTRACT.md` §5.2). | `code` | Meaning | Suggested UX | |---|---|---| | `satquery_error` | **Base class** — the fallback when a more specific code does not apply | Generic failure. Treat an unexpected occurrence as a defect | | `input_error` | The uploaded input could not be read | Ask the user to re-upload | | `raster_read_error` | Not a readable TIFF/GeoTIFF | "This file is not a readable GeoTIFF" | | `missing_crs` | No coordinate reference system | "This image has no georeferencing" | | `unsupported_bands` | Band layout unsupported | Explain expected bands | | `oversized_image` | Exceeds the pixel budget | Offer downsampling | | `pair_incompatible` | The two images do not match | Prompt for a better pair | | `pair_misaligned` | Not co-registered | Explain alignment requirement | | `temporal_pair_invalid` | Two distinct acquisitions required | Ask for a second date | | `routing_error` | Request could not be interpreted | Offer `force_task` | | `unsupported_query` | No specialist supports this | Show the capability list | | `invalid_request` | Inputs do not support the task | Suggest a valid task | | `workflow_plan_error` | Workflow could not be planned | Retry; report if persistent | | `specialist_error` | A specialist failed | Generic failure | | `model_load_error` | A model could not be loaded | **Defect** — surface it | | `model_unavailable` | Model not available in this environment | Disable the capability | | `out_of_memory` | OOM; retry at reduced resolution | Suggest a smaller image | | `specialist_timeout` | Processing timed out | Offer retry | | `schema_validation_error` | The system produced a malformed result | **Defect** — always report | | `coordinate_error` | Invalid spatial coordinates | **Defect** | | `confidence_range_error` | Confidence out of range | **Defect** | | `leakage_violation` | A data isolation rule was violated | **Defect** — never user-facing | | `benchmark_freeze_error` | The benchmark is not frozen | Evaluation-only | (`docs/API_CONTRACT.md` §5.2) > *"**Render `user_message` as the default and override specific codes with better copy.** Do not > invent a mapping from `detail` — it is not stable."* (`docs/API_CONTRACT.md` §5.2) The class hierarchy that produces these codes (`core/errors.py`): ```mermaid classDiagram class SatQueryError { code = "satquery_error" } class InputError { code = "input_error" } class RasterReadError { code = "raster_read_error" } class MissingCRSError { code = "missing_crs"; recoverable default True } class UnsupportedBandsError { code = "unsupported_bands" } class OversizedImageError { code = "oversized_image"; recoverable default True } class PairCompatibilityError { code = "pair_incompatible" } class PairMisalignmentError { code = "pair_misaligned" } class TemporalPairError { code = "temporal_pair_invalid" } class RoutingError { code = "routing_error" } class UnsupportedQueryError { code = "unsupported_query" } class InvalidRequestError { code = "invalid_request" } class WorkflowPlanError { code = "workflow_plan_error" } class SpecialistError { code = "specialist_error" } class ModelLoadError { code = "model_load_error" } class ModelUnavailableError { code = "model_unavailable"; recoverable default True } class OutOfMemoryError { code = "out_of_memory"; recoverable default True } class SpecialistTimeoutError { code = "specialist_timeout"; recoverable default True } class SchemaValidationError { code = "schema_validation_error" } class CoordinateError { code = "coordinate_error" } class ConfidenceRangeError { code = "confidence_range_error" } class LeakageError { code = "leakage_violation" } class BenchmarkFreezeError { code = "benchmark_freeze_error" } SatQueryError <|-- InputError InputError <|-- RasterReadError InputError <|-- MissingCRSError InputError <|-- UnsupportedBandsError InputError <|-- OversizedImageError SatQueryError <|-- PairCompatibilityError PairCompatibilityError <|-- PairMisalignmentError PairCompatibilityError <|-- TemporalPairError SatQueryError <|-- RoutingError RoutingError <|-- UnsupportedQueryError SatQueryError <|-- InvalidRequestError SatQueryError <|-- WorkflowPlanError SatQueryError <|-- SpecialistError SpecialistError <|-- ModelLoadError SpecialistError <|-- ModelUnavailableError SpecialistError <|-- OutOfMemoryError SpecialistError <|-- SpecialistTimeoutError SatQueryError <|-- SchemaValidationError SchemaValidationError <|-- CoordinateError SchemaValidationError <|-- ConfidenceRangeError SatQueryError <|-- LeakageError SatQueryError <|-- BenchmarkFreezeError ``` ### 12.7 The `recoverable` flag — what it means and where it comes from `recoverable` is a field on the base exception, defaulting to `False`: ```python class SatQueryError(Exception): """Base class for every SatQuery failure. Attributes: code: stable machine-readable identifier, used in traces. user_message: text safe to show the operator. detail: technical detail for the execution trace (never chain-of-thought). recoverable: whether the controller may continue with a fallback. """ code: str = "satquery_error" user_message: str = "An internal error occurred." def __init__( self, detail: str = "", *, user_message: str | None = None, recoverable: bool = False, context: dict[str, Any] | None = None, ) -> None: ``` (`core/errors.py:82-106`) Four subclasses override the default to `True`, each with a stated reason: | Class | `recoverable` | Reason | |---|---|---| | `MissingCRSError` | `True` | *"Degraded, not fatal: non-geospatial analysis may still be possible."* (`core/errors.py:135`) | | `OversizedImageError` | `True` | *"Recoverable via downscale."* (`core/errors.py:150`) | | `ModelUnavailableError` | `True` | *"Recoverable: the controller degrades the workflow."* (`core/errors.py:220`) | | `OutOfMemoryError` | `True` | *"Recoverable: retry at lower resolution."* (`core/errors.py:230`) | | `SpecialistTimeoutError` | `True` | see below | `SpecialistTimeoutError` carries the longest justification in the file, and it documents a **defect that was fixed**: > *"`: Recoverable, per `docs/API_CONTRACT.md` section 5.1, which maps 504 with `recoverable: true`. > Two independent reasons: > > 1. `docs/API_CONTRACT.md` is the frozen frontend-facing contract. A frontend that reads > `recoverable: false` will not offer a retry for the one failure the contract explicitly tells it > to retry. > 2. The plan's Failure Matrix (§57) lists Timeout with the recovery "abort specialist" and the > fallback "partial result" -- i.e. the controller continues rather than failing the request. A > terminal `recoverable=False` contradicts that. > > Inheriting `False` from `SatQueryError` was the defect this default corrects. Note the controller > currently only reuses `.code` for its budget-skip trace entry (`core/controller.py:464`), so nothing > in the pipeline constructed this class and the wrong default was never observable from the inside -- > only from a client."* (`core/errors.py:240-255`) The client's use of the flag is one branch: ```js if (!res.ok) { const { error } = await res.json(); if (error.recoverable) { /* offer a retry affordance */ } else { /* terminal: explain, do not offer retry */ } } ``` (`docs/FRONTEND_INTEGRATION.md` §5) The shipped client keeps the flag on its error object so a caller can branch on it: ```python err.recoverable = !!opts.recoverable; ``` (`frontend/assets/js/live.js:160`) ### 12.8 `DEFECT_CODES` — the five that mean the system is broken ```python #: Codes that indicate the *system* is broken, not the request. The frontend is #: instructed to surface these rather than swallow them (`API_CONTRACT.md` 5.2). DEFECT_CODES: frozenset[str] = frozenset( { "model_load_error", "schema_validation_error", "coordinate_error", "confidence_range_error", "leakage_violation", } ) ``` (`gateway/policy.py:95-105`) > *"For these, show a generic failure **and** capture the `request_id` so it can be reported. Do not > attempt to explain them to the user."* (`docs/FRONTEND_INTEGRATION.md` §5) ### 12.9 Gateway-origin codes — a separate set, and why they must stay separate There is exactly **one** code a client can receive that is *not* in the taxonomy: | `code` | Meaning | Suggested UX | |---|---|---| | `rate_limited` | **Gateway-origin.** The proxy's per-IP rate limit refused the request; it never reached the Space. `429`, and `Retry-After` is set | Wait `Retry-After` seconds, then retry. Not a bug | (`docs/API_CONTRACT.md` §5.3) ```python GATEWAY_ORIGIN_CODES: frozenset[str] = frozenset({"rate_limited"}) _CODE_STATUS["rate_limited"] = 429 ``` (`gateway/policy.py:120-122`) > *"`tests/unit/test_gateway_responsibilities.py` asserts that §5.2 and `core/errors.py` are in > **exact one-to-one correspondence** (23 codes), and `tests/unit/test_gateway_policy.py` asserts that > a gateway-origin code may **never** shadow a taxonomy code. Both hold only if the two sets stay > disjoint — so `rate_limited` is documented here, beside the taxonomy rather than inside it, and the > correspondence test keeps its meaning."* (`docs/API_CONTRACT.md` §5.3) The rule that still holds: > *"the gateway never invents a code for an error that ORIGINATED in the Space. Those pass through > unchanged."* (`gateway/policy.py:118-119`) ### 12.10 The status map is total, and an unknown code cannot become a 200 ```python #: HTTP status per SatQuery error code. From `docs/API_CONTRACT.md` section 5.1. #: #: The mapping is TOTAL over the taxonomy in `core/errors.py`: every code either #: appears here or falls through to 500, and `tests/unit/test_gateway_policy.py` #: asserts that no code silently maps to the wrong class. A gateway that let an #: unknown code produce a 200 would turn a defect into a success. _CODE_STATUS: dict[str, int] = { "input_error": 400, "raster_read_error": 400, "missing_crs": 400, "unsupported_bands": 400, "oversized_image": 413, "pair_incompatible": 422, "pair_misaligned": 422, "temporal_pair_invalid": 422, "routing_error": 422, "unsupported_query": 422, "invalid_request": 422, "workflow_plan_error": 500, "specialist_error": 500, "model_load_error": 503, "model_unavailable": 503, "out_of_memory": 503, "specialist_timeout": 504, "schema_validation_error": 500, "coordinate_error": 500, "confidence_range_error": 500, "leakage_violation": 500, "benchmark_freeze_error": 500, # The base class. A bare `satquery_error` means no specific code applied, # which is an internal failure, not a client error. "satquery_error": 500, } ``` (`gateway/policy.py:61-93`) > **Note a real divergence between the map and the contract table.** `_CODE_STATUS` maps > `routing_error` to **422**, while `docs/API_CONTRACT.md` §5.1 maps `404`/`405` to `routing_error`. > The Space's and gateway's transport handlers take the status from the **exception** > (`status_code=exc.status_code`) and use `translate_error` only for the *body*, which is exactly why > the divergence is invisible on that path — and it is stated here rather than left for a reader to > trip over. ### 12.11 `detail` never carries a path F-15 (owner ruling 2026-09-23): *sanitize all client-facing exception messages; retain full exception details only in server-side diagnostics.* The scrubber reduces absolute paths to their basename: ```python def scrub_paths(text: str | None) -> str | None: """Reduce every absolute filesystem path in `text` to its final component. ... Exception messages in this repo routinely embed an absolute path: `specialists/optical_sar/croma.py` raises *"CROMA requires the vendored 'use_croma.py', which is not in 'C:\\\\...\\\\empty_vendor_dir'"* and `specialists/change/stanet.py` raises *"could not read encoder weights from C:\\\\..."*. Those strings reach client-visible fields, and `API_CONTRACT.md` section 7 records that v1 has **no auth**. """ ``` (`core/errors.py:39-68`) > *"URLs are left intact on purpose: `https://github.com/antofuller/CROMA` appears inside one of the > very messages this scrubs, and mangling it would be a worse defect than the one being repaired."* > (`core/errors.py:65-67`) Relative paths are deliberately **not** matched: > *"A rule broad enough to catch `artifacts/change/head.pt` also catches `and/or` and the path segments > of a URL, and a scrubber that mangles ordinary prose is a worse defect than the disclosure it > fixes."* (`core/errors.py:24-27`) Two further disclosures were closed on the same ruling and are worth naming because they are the same class of bug: - **F-13.** `trace.inputs` echoed `request.assets` *after* handles had become paths. Measured end-to-end: *"the client sent the handle `asset_d243f7f85d8c2f3c02981f0af9737f01` and received back `C:\Users\anish\sq_scratch\...\assets\asset_d243...7f01.tif`."* The fix is `_asset_label(a)` — the basename (`core/controller.py:263-277`). - **F-14.** The same disclosure again, one record later, in the `PARSE` step's `detail` (`core/controller.py:288-300`). Both now use the **one** `_asset_label` rule, because *"a second copy of an existing rule is a second thing that can drift from it."* ### 12.12 The transport-failure classifier — a classification, not an exception dump F-15c. The gateway's transport branch used to publish the exception's own class name and message: ```python #: Transport failures, mapped to a CONTRACT-level classification. Ordered, most #: specific first. Matched on the exception's MRO class names rather than with #: `isinstance`, so the classifier keeps working when `httpx` is absent (the #: caller passes `httpx=None` in that case) and does not couple the client-facing #: vocabulary to a third-party type hierarchy that can be renamed. _TRANSPORT_FAILURES: tuple[tuple[str, str], ...] = ( ("TimeoutException", "the upstream did not respond within the gateway timeout"), ("ConnectError", "the upstream could not be reached"), ("ProxyError", "the gateway's egress proxy refused the connection"), ) _TRANSPORT_FAILURE_FALLBACK = "the upstream request failed at the transport layer" ``` (`gateway/app.py:125-136`) > *"what the client needs is *which kind* of transport failure this was -- unreachable, timed out, or > refused by the proxy -- because those imply different operator actions. What it must not receive is > the exception's own text, which names the gateway's HTTP client, its internals, and potentially a > proxy URL or a path. The full exception still reaches the operator through `_log.error(..., > exc_info=exc)` in the caller, so nothing is lost -- it is moved, not deleted."* > (`gateway/app.py:141-149`) ### 12.13 A non-JSON upstream body is a defect, on every status ```python content_type = upstream.headers.get("content-type", "") if "application/json" not in content_type and path != "/v1/health": status, err_body = translate_error( "schema_validation_error", "The analysis service returned a malformed response.", detail=( f"content-type={content_type!r} for {path} " f"(upstream status {upstream.status_code})" ), request_id=decision.request_id, ) # 502 regardless of the upstream's own status: the fault the CLIENT can # act on is "the gateway's upstream misbehaved", and echoing e.g. a 404 # from a reverse proxy would suggest the API path itself was wrong. return JSONResponse(status_code=502, content=err_body, headers=decision.headers) ``` (`gateway/app.py:616-630`) > *"The guard covers EVERY status, not only 2xx. It originally read `upstream.status_code < 400`, > which meant a non-JSON 4xx/5xx from the upstream -- a proxy error page, an HTML 502 from a load > balancer, a plain-text stack trace from a misconfigured Space -- was forwarded verbatim. […] > the upstream's own wording was passed through unfiltered, which is how this was found: a sandbox > egress proxy returned a 502 whose body disclosed `os error 10061`."* (`gateway/app.py:600-612`) `/v1/health` is exempt because *"a liveness probe may legitimately answer non-JSON, and it consumes no GPU quota."* (`gateway/app.py:614-615`) The orchestrator applies the same rule in its own `_proxy()`: ```python except Exception: _log.error( "non-JSON upstream response from %s (status %s)", url, resp.status_code ) return _envelope( "schema_validation_error", "The inference engine returned a non-JSON response.", f"upstream status {resp.status_code}", status=502, recoverable=True, ) ``` (`deploy/render/main.py:404-414`) ### 12.14 The orchestrator's own error codes `deploy/render/main.py` defines three orchestrator-local codes, which are **not** in the 23-code taxonomy because they describe the proxy, not an analysis: | Class | `code` | status | `recoverable` | |---|---|---|---| | `WakeTimeout` | `wake_timeout` | `504` | `True` | | `OrchestratorConfigError` | `orchestrator_config_error` | `500` | `False` | | `OrchestratorUpstreamError` | `upstream_unreachable` | `502` | `True` | (`deploy/render/main.py:247-272`) and two more codes minted at the call sites: | `code` | Where | status | |---|---|---| | `upstream_error` | `deploy/render/main.py:395` (generic `httpx.HTTPError`) | `502` | | `invalid_request` | `deploy/render/main.py:480` (body was not valid JSON) | `400` | `wake_timeout`'s message is deliberately actionable: ```python class WakeTimeout(OrchestratorError): """The Codespace did not reach `available` within the wake timeout.""" code = "wake_timeout" message = "The inference engine did not start in time. Please retry shortly." status = 504 recoverable = True ``` (`deploy/render/main.py:247-253`) and the orchestrator's envelope carries **four** fields, not six — it has no `request_id` or `run_id` to offer at that layer: ```python body = { "error": { "code": code, "message": message, "detail": detail or None, "recoverable": bool(recoverable), } } ``` (`deploy/render/main.py:283-290`) > **This is a real shape difference between the two layers' envelopes.** The gateway's envelope > carries `request_id` and `run_id` (`gateway/policy.py:161-170`); the orchestrator's carries neither > (`deploy/render/main.py:283-290`). A client that reads `error.request_id` must tolerate its absence > when the error originated at the orchestrator. ### 12.15 `tunnel_offline` — a code that exists only on the deployed backend `docs/DEPLOYMENT_TOPOLOGY.md` §2 records that when the tunnel agent is absent, `POST /api/infer` parks until `SATQUERY_TUNNEL_TIMEOUT_S` (150 s) and then returns **`tunnel_offline` (503, `recoverable:true`)**. That code is minted by the deployed `SatQuery-Backend/main.py`, which is **not** in this repository (B-03). It is therefore: `IMPLEMENTED (not in this repository)` — the code and its semantics are recorded in `docs/DEPLOYMENT_TOPOLOGY.md` §2 and `docs/FINAL_DELIVERY_TODO.md` §5 (B-07), but the source line that emits it was not read for this chapter. --- ## 13. The five entrypoint requirements `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3 fixes five requirements for the inference entrypoint. They are reproduced in `app/space_app.py`'s module docstring under the heading *"THE FIVE ENTRYPOINT REQUIREMENTS"*, and each is implemented in a way a reader can check. ### 13.1 Requirement 1 — import cheaply and without torch > *"**Import cheaply and without torch.** `GET /v1/health` and `/v1/capabilities` must answer on CPU > with no GPU and no model load. This follows the existing project convention: `app/serving.py` > imports `build_*` functions lazily inside the builders precisely so that importing the module does > not pull the model stack."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) The implementation defers both the web framework and the model stack: ```python def get_controller() -> Any: """Build (once) and return the serving controller. Requirement 2: this delegates to `app.serving.build_serving_controller()`, […] The call is deferred to first use, not performed at import: requirement 1 says importing this module must not pull the model stack. """ global _CONTROLLER if _CONTROLLER is None: from app.serving import build_serving_controller _CONTROLLER = build_serving_controller() return _CONTROLLER ``` (`app/space_app.py:168-185`) ```python def build_space_app() -> Any: """... `FastAPI` is imported HERE, inside the function, which is correct and deliberate: requirement 1 says importing this module must not pull the web framework, and a name used only at build time does not need to be in the module namespace. The annotation subjects (`Request`, `Response`, `JSONResponse`) are the opposite case and ARE module-level -- see the import comment for why the distinction is load-bearing. """ from fastapi import FastAPI ... ``` (`app/space_app.py:409-432`) The ZeroGPU decorator is applied **conditionally** so the module imports everywhere: ```python def _spaces_module() -> Any | None: """Import `spaces` if present. ZeroGPU Spaces ship it; a CPU-only machine does not. Returning None rather than raising keeps the module importable everywhere, which requirement 1 of section 3.3 demands. """ try: import spaces # type: ignore[import-not-found] return spaces except Exception: return None ``` (`app/space_app.py:129-141`) > **The decoration has never executed.** `app/space_app.py`'s own implementation note is explicit: > *"The consequence is recorded in `docs/PHASE19_FINAL_HARDENING.md`: the ZeroGPU decoration has > **never executed** here. It is specified from finding C-8 and the frozen `gpu_duration_*` values, > and that is all it is."* (`app/space_app.py:40-42`) ### 13.2 Requirement 2 — reuse `app.serving`'s composition root > *"**Reuse `app.serving`.** `build_serving_controller()` is the existing, tested composition root. It > already wires the change head and the change-VQA head through the registry's `builders=` override — > the mechanism that avoids editing `configs/base.yaml` and therefore avoids moving `Config.hash`. > **The entrypoint must not reimplement this wiring**; doing so would duplicate the F2 train/serve-skew > fix."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) The single call site, with the reasoning at the call: ```python """Requirement 2: this delegates to `app.serving.build_serving_controller()`, the existing composition root that wires the change head and the change-VQA head through the registry's `builders=` override. That override is a call-site argument rather than config, which is what keeps `Config.hash` unchanged (requirement 5).""" ``` (`app/space_app.py:171-175`) ### 13.3 Requirement 3 — degrade, do not crash, but corrupt artifacts raise `ModelLoadError` > *"**Degrade, do not crash.** `app/serving.py` documents the contract: absent artifacts degrade; > *corrupt* artifacts raise `ModelLoadError`. A capability whose artifacts are absent reports > `available: false` **with a reason**, and `/v1/health` returns `status: "degraded"`. A Space that > refuses to boot because an optional artifact is absent is a worse failure than one that serves a > reduced capability set."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) The **corrected** statement of what a bare host reports: > *"**Corrected 2026-09-22.** This requirement previously added *"On a Space with no artifacts, every > capability reports `available: false`"*. That is no longer the behaviour and was never the right > target: `change` and `change_vqa` are shipped in-repo, so they report **available** even on a bare > host. The accurate statement is that an *unservable* capability is reported unavailable **and still > listed** — never omitted from the enumeration, because a frontend cannot disable an affordance it was > never told about."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) The two error classes that carry the distinction are separate types, not one type with a flag: ```python class ModelLoadError(SpecialistError): code = "model_load_error" user_message = "A required model could not be loaded." class ModelUnavailableError(SpecialistError): code = "model_unavailable" user_message = "A required model is not available in this environment." # Recoverable: the controller degrades the workflow. ``` (`core/errors.py:212-225`) `model_load_error` is a **DEFECT code** (§12.8) and maps to `503`; `model_unavailable` is recoverable and also maps to `503`. The distinction is `recoverable`, and it is carried into the UI. ### 13.4 Requirement 4 — never load a model for a metadata request > *"**Never load a model for a metadata request.** Health and capabilities read artifact *presence* > (filesystem) and configuration, not weights."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) The adapter is the implementation, and its docstring names the method it must not reuse: ```python def describe_deployment() -> dict[str, Any]: """Report what this deployment can do, without loading any model. ... It does **not** reuse `AnalystController.health()`, which its own docstring documents as "Constructs everything" -- the opposite of what a metadata request may do. Per the ruling, that method is retired as a public API path. """ from app.deployment import deployment_report return deployment_report().as_internal() ``` (`app/space_app.py:188-215`) > *"The adapter reads the spec table plus the filesystem and never calls `build()` or `build_all()`. It > is asserted by `test_it_does_not_use_the_constructing_controller_health`, which fails if the metadata > path reaches `AnalysisController` — the class whose `health()` *constructs everything*."* > (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3, requirement 4) ### 13.5 Requirement 5 — honour the config hash; never merge `deploy.yaml` > *"**Honour the config hash.** The entrypoint must not mutate the config, merge `deploy.yaml` into the > registry, or otherwise move `Config.hash` off `78f1e3700da15aa1`."* > (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3) Three places in the code that exist *because* of this requirement: **The GPU duration table.** `change_vqa` has no key of its own and reuses `change`: ```python #: ZeroGPU duration per task, from `configs/deploy.yaml` -- the FROZEN values, #: not new guesses. `change_vqa` has no key of its own and reuses `change`, #: because adding a key would move `Config.hash` off `78f1e3700da15aa1` #: (`docs/DEPLOYMENT_ARCHITECTURE.md` section 3.4). GPU_DURATIONS: dict[str, int] = { "vqa": 20, "caption": 20, "grounding": 45, "change": 30, "optical_sar": 45, "change_vqa": 30, } ``` (`app/space_app.py:105-116`) A missing duration is a programming error, not a default: ```python if task not in GPU_DURATIONS: raise KeyError( f"no gpu_duration_* is declared for {task!r}; add it to " f"configs/deploy.yaml (which moves Config.hash) or map it to an " f"existing task. Do not guess a duration." ) ``` (`app/space_app.py:152-157`) **The asset sizing knobs are read from the environment, not from `configs/base.yaml`:** ```python """Read from the environment rather than from `configs/base.yaml` on purpose: adding a key there moves `Config.hash` off `78f1e3700da15aa1` and invalidates the frozen Phase-9 benchmark. Sizing is deployment state, and the same reasoning already governs `SATQUERY_MAX_FILE_BYTES`.""" ``` (`app/space_app.py:263-266`) **The `builders=` override is a call-site argument, not config** — which is what requirement 2's docstring calls out as *"what keeps `Config.hash` unchanged"* (`app/space_app.py:174-175`). The frozen hash itself is `78f1e3700da15aa1`, and it appears in the response: ```json "config_hash": "78f1e3700da15aa1" ``` (`docs/API_CONTRACT.md` §2.4; `docs/DEPLOYMENT_TOPOLOGY.md` §5) --- ## 14. The two annotation traps — how a FastAPI app becomes silently wrong Both traps have the same root cause and **opposite diagnosability**. They are documented at length in the source because both fired in this codebase, and because a future handler can re-introduce either by moving one import. ### 14.1 G-1 — an unresolvable *parameter* annotation is silently reinterpreted The module uses `from __future__ import annotations`, so `request: Request` is a **string** at runtime. FastAPI resolves it with `eval(annotation, func.__globals__)`, so the name must be in the module's globals: ```python # `Request` is bound at MODULE scope, and this is load-bearing rather than # stylistic -- it is the G-1 defect, and it would be re-introduced here by a # local import. # # This module uses `from __future__ import annotations`, so `request: Request` # in the `/v1/assets` handler below is a *string* at runtime. FastAPI resolves # it with `eval(annotation, func.__globals__)`, so the name must be in this # module's globals. If `Request` were imported inside `build_space_app`, it would # be a local of that function, `eval` would fail, and FastAPI would **not raise** # -- it would silently reinterpret the parameter as a required *query* parameter # named `request`, answering every upload with # `422 {"detail":[{"loc":["query","request"]}]}` and never entering the handler. ``` (`app/space_app.py:55-66`) The same trap is documented in `gateway/app.py` with the measured symptom: > *"FastAPI then does not raise -- it silently falls back to treating the parameter as a *query* > parameter named `request`. The observable consequence was that every POST to `/v1/analyze` and > `/v1/assets` returned > > ``` > 422 {"detail":[{"loc":["query","request"],"msg":"Field required"}]} > ``` > > without ever entering the handler: the request body was never read, the gateway's own validation > never ran, and the error envelope was FastAPI's `{"detail": ...}` rather than the contract's > `{"error": {...}}`. No test caught this because no test could import FastAPI when this file was > written."* (`gateway/app.py:63-73`) ### 14.2 The return annotation — an unresolvable one *raises* ```python # `Response` is bound for the same reason, and its necessity was discovered the # hard way. Every route in this module is annotated `-> JSONResponse` while # `JSONResponse` was imported INSIDE `build_space_app`. With # `from __future__ import annotations`, FastAPI evaluates that return annotation # against `space_app.__globals__`, where `JSONResponse` did not exist, so # `add_api_route` raised: # # pydantic.errors.PydanticUndefinedAnnotation: name 'JSONResponse' is not defined # # and `build_space_app()` could not be called at all. This is the SAME class of # defect as G-1 -- a name needed by `eval` at route-registration time bound in a # narrower scope than the annotation evaluator can see -- and it is why every # route annotation subject in this file is now module-level. # # Note this one FAILS LOUDLY, where G-1 failed silently. The difference is # whether the unresolved name is a parameter annotation (FastAPI falls back to a # query parameter) or a return annotation (FastAPI has no fallback and raises). ``` (`app/space_app.py:73-89`) ### 14.3 The asymmetry, stated as the lesson | Unresolved name is… | FastAPI behaviour | Symptom | |---|---|---| | a **parameter** annotation | falls back to a required query parameter | `422 {"detail":[{"loc":["query","request"]}]}`, handler never runs, **silent** | | a **return** annotation | no fallback; raises at `add_api_route` | `PydanticUndefinedAnnotation: name 'JSONResponse' is not defined`; the app cannot be built, **loud** | > *"Note the asymmetry with G-1, which is worth internalising: an unresolvable PARAMETER annotation is > silently reinterpreted (FastAPI treats it as a query parameter and the handler never runs), while an > unresolvable RETURN annotation raises. Same root cause, opposite diagnosability. Binding the name > here fixes both and makes the difference moot."* (`gateway/app.py:101-105`) ### 14.4 What this means for a maintainer Three rules, all derived from the above: 1. **Every annotation subject must be module-level.** `Request`, `Response`, `JSONResponse` are imported at module scope in both files with `# noqa: E402` and a comment (`app/space_app.py:71`, `:90`, `:91`; `gateway/app.py:85`, `:86`, `:106`). 2. **A name used only at build time may stay inside the function.** `FastAPI` is imported inside `build_space_app` deliberately, and the docstring says why the distinction is load-bearing (`app/space_app.py:418-423`). 3. **A route rename is not the only way to disable rate limiting.** The `is_analyze` override exists because a path-derived cost check can be silently wrong (§3.2) — the same class of "make the implicit explicit" repair. --- ## 15. What is NOT RUN, OPEN or BLOCKED for this topic The style guide requires this list explicitly, and it must not be softened. | Item | Status | Detail | |---|---|---| | Multipart upload into `/v1/analyze` | **NOT IMPLEMENTED** | Option B was rejected; the JSON form plus `/v1/assets` is the surface (`docs/API_CONTRACT.md` §2.4, §2.5.1) | | `/v1/assets` exercised against a **live** deployment end to end | **NOT RUN at the contract level** | `docs/FRONTEND_INTEGRATION.md` §9: *"Upload is defined but not yet exercised against a live deployment. […] no request has traversed the real gateway-to-Space path, because no egress to it exists in the build environment."* The **live** path that has been exercised is the deployed `/api/assets` → tunnel → `/v1/assets` chain (E-03, E-05), which is a different statement from the integration suite's | | Multipart upload through the real gateway | **NOT RUN** | The shipped client sends raw bytes (§8.1); the multipart description has no live evidence | | `GET /v1/analyze/` trailing-slash behaviour | **VERIFIED (2026-09-22)** | *"verified against the running app on 2026-09-22"* (`docs/API_CONTRACT.md` §5.1) | | End-to-end **benchmark** of the API surface (latency, throughput) | **NOT RUN — none exists** | *"Latency is not characterized. No cold-start or throughput measurement has been taken against a live Space."* (`docs/FRONTEND_INTEGRATION.md` §9) | | Rate-limit **values** as contract constants | **NOT SPECIFIED BY THE PLAN** | *"The gateway must choose them; ask the maintainer"* (`docs/API_CONTRACT.md` §8) | | Streaming / progress API | **NOT IN v1** | `docs/API_CONTRACT.md` §8 | | Authentication | **DELIBERATELY ABSENT** | plan §74; `docs/API_CONTRACT.md` §7 | | `tunnel_offline` code | **IMPLEMENTED (not in this repository)** | recorded in `docs/DEPLOYMENT_TOPOLOGY.md` §2; the emitting source is in the private `SatQuery-Backend` repo | | The `deployment` block of `/api/capabilities` | **STALE — known defect** | claims `huggingface-spaces`/`zerogpu` on a Render+tunnel deployment (`docs/FINAL_DELIVERY_TODO.md` §1.7 item 6) | | `gateway/app.py` route wiring actually executing | **NOT RUN** | *"the route wiring below has never executed. Its logic is not untested -- every decision it makes lives in `gateway.policy`, which has 51 passing tests -- but the FastAPI plumbing […] is **specified and reviewed, not run**."* (`gateway/app.py:22-27`) | | The `x-satquery-transport: tunnel` header as a **contract** field | **MEASURED, not contracted** | It is a live fact (E-03) and a client read (`live.js:315`), but it is **not** in `docs/API_CONTRACT.md` | | `model_load_error` observed from a live client | **NOT RUN** | The class and its `503` mapping exist; no measurement of it reaching a client was found | | **UNKNOWN — not established from the available evidence** | — | the exact HTTP status the **deployed** tunnel returns for each failure mode; the rate-limit values in force on the live Render service; whether `/v1/assets` is enabled on the live Codespace (`SATQUERY_ASSET_ENABLED`/`SATQUERY_ASSET_DIR` are not in the measured live env-var list in `docs/DEPLOYMENT_TOPOLOGY.md`) | ### 15.1 A note on what "VERIFIED" means for this contract `docs/API_CONTRACT.md` §8 is explicit that the contract's own verification has a boundary: > *"`docs/ITEM5_INTEGRATION_SUITE_SCOPE.md` records what the 26-test integration suite proves (the > app's *boundary*, in-process) and what only a live deployment can prove (reachability, cold start, > memory ceilings). Read it before treating a green `tests/integration` run as evidence about a > deployment — **no test in this repository dials a network address**, including this contract's own > `/v1/*` examples."* (`docs/API_CONTRACT.md` §8) So this chapter distinguishes three grades of claim, and never blends them: 1. **shape** — verified against the Pydantic models and the handlers (in-process); 2. **behaviour** — verified against a running app on a named date (the `307`, the `404`/`405` envelopes, the two body-cap measurements); 3. **deployment** — verified against the live Render + tunnel stack (E-03, E-05, E-11, E-14), and only for the paths those runs exercised. --- ## 16. Where the evidence lives | Claim class | File | What it establishes | |---|---|---| | Request/response shapes | `core/schemas.py` | `AnalysisRequest` (:412), `ResultEnvelope` (:421), `HealthStatus` (:430), `SpecialistResult` (:325), `ExecutionTrace` (:296) | | Error taxonomy | `core/errors.py` | 23 codes, `recoverable` defaults, `scrub_paths` | | The four endpoints | `app/space_app.py` | `:521` health, `:549` capabilities, `:555` assets, `:661` analyze | | Entrypoint requirements | `app/space_app.py:17-29`; `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3 | the five requirements | | G-1 and the return-annotation trap | `app/space_app.py:55-91`; `gateway/app.py:53-106` | both traps, with the measured symptom | | Status map, `DEFECT_CODES`, gateway-origin codes | `gateway/policy.py` | `:61-93`, `:95-105`, `:120-122` | | CORS | `gateway/policy.py:429-454`, `:698-753`; `deploy/render/main.py:139-216` | allowlist, F-2, dev origins | | Body caps, F-6, F-7, F-9 | `gateway/policy.py:606-618`; `gateway/assets.py:100-148`; `app/space_app.py:313-378`, `:593-621` | both caps and both measurements | | Asset store | `gateway/assets.py` | handle opacity (:466), TTL (:370), capacity (:328), allowlist (:318) | | `/api/*` mirror | `deploy/render/main.py` | route table (:22-26), handlers (:444-508) | | Gateway allowlists | `gateway/app.py:170-199` | `PROXIED_ROUTES`, `BLOCKED_ROUTES`, `COSTLY_ROUTES` | | The no-retry rule | `gateway/app.py:550-552`; `docs/DEPLOYMENT_TOPOLOGY.md` §2; `docs/FRONTEND_INTEGRATION.md` §6.1 | three independent statements | | Verified live example | `docs/FINAL_DELIVERY_TODO.md` §6 E-03 | `POST /api/infer {}` → `422 invalid_request`, `x-satquery-transport: tunnel` | | Live validation | `docs/FINAL_DELIVERY_TODO.md` §6 E-11, E-14 | 8/8 × 3 passes, `mock_nodes=0`, all `/api/*` → `onrender.com` | | Client-facing specification | `docs/API_CONTRACT.md` (916 lines) | read fully; the authority for everything a client author reads | | Integration guidance | `docs/FRONTEND_INTEGRATION.md` (417 lines) | §9 "what is NOT guaranteed" | | Active topology | `docs/DEPLOYMENT_TOPOLOGY.md` (248 lines) | the `/api/*` route list (§3.2), the measured transport (§2) | | Blueprint | `render.yaml` (25 lines) | the declared Render env vars | ### 16.1 Cross-references | For… | Read | |---|---| | the topology, the wake flow, the tunnel, the health payload | [02 — Deployment Topology](./02-deployment-topology.md) | | the controller's nine states, the planner, the raster contract, the events | [03 — Request Lifecycle](./03-request-lifecycle.md) | | the evidence record, the confidence rules, the trace's fields | [06 — Evidence and Confidence](./06-evidence-and-confidence.md) | | the static tier, the Analyze console, the client, the harness | [09 — Frontend](./09-frontend.md) | | the health payload's every field, the trace as an observability object, the runbook | [10 — Observability and Operations](./10-observability-and-ops.md) |