SatQuery / docs /architecture /08-api-contract.md
thundercode's picture
release: add docs/architecture/08-api-contract.md
e09797a verified
|
Raw
History Blame Contribute Delete
127 kB
# 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<string,string>` | 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: <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://<gateway-host>/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) |