Image-to-Text
PyTorch
Safetensors
PEFT
English
remote-sensing
satellite-imagery
earth-observation
change-detection
visual-grounding
image-captioning
visual-question-answering
optical-sar-fusion
sar
multimodal
lora
Instructions to use thundercode/SatQuery with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use thundercode/SatQuery with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
| # 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) | | |