thundercode commited on
Commit
a6374c1
·
verified ·
1 Parent(s): d08aa24

release: add docs/API_CONTRACT.md

Browse files
Files changed (1) hide show
  1. docs/API_CONTRACT.md +929 -0
docs/API_CONTRACT.md ADDED
@@ -0,0 +1,929 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Contract (ORIGINAL REFERENCE)
2
+
3
+ > **STATUS: the project's authoritative API-contract reference**, preserved from the source repository
4
+ > where the documentation-guard tests validate it. It is included here because those tests and several
5
+ > documents refer to it by name.
6
+ >
7
+ > For the **release-facing** description of the same contract — including the shipped topology, the
8
+ > gateway mirror, the error taxonomy and the entrypoint requirements — see
9
+ > [architecture/08-api-contract.md](architecture/08-api-contract.md). Where the two differ in
10
+ > emphasis, that chapter describes the shipped system.
11
+
12
+ ---
13
+
14
+ # SatQuery AI — Backend API Contract (v1)
15
+
16
+ **Status:** SPECIFICATION — the backend implementation is described in
17
+ `docs/DEPLOYMENT_ARCHITECTURE.md`; endpoints below are the contract the frontend
18
+ MUST be built against.
19
+
20
+ **Audience:** the frontend agent (Cloudflare Pages), and any other API consumer.
21
+
22
+ **Authority:** the request/response **shapes** are not invented here. They are the
23
+ existing, tested Pydantic models in `core/schemas.py`. This document describes
24
+ them; it does not define new ones. Where a shape is described, the model name is
25
+ given so it can be read directly in the source.
26
+
27
+ ---
28
+
29
+ ## 1. Conventions
30
+
31
+ | Aspect | Value |
32
+ |---|---|
33
+ | Scheme | HTTPS only (the gateway redirects plain HTTP) |
34
+ | Base path | `/v1/` |
35
+ | Content types | Requests: `application/json` (or `multipart/form-data` for image upload). Responses: `application/json` |
36
+ | Character encoding | UTF-8 |
37
+ | Timestamps | ISO 8601 with `Z` offset, e.g. `2026-09-22T04:12:21.000Z` |
38
+ | Field naming | `snake_case` throughout (matches the Pydantic models) |
39
+ | Versioning | Path version (`/v1/`). A change that removes a field, changes a field's type or meaning, or adds a required field moves to `/v2/`. A purely additive field that existing clients can ignore does not bump the version — but see §1.1: because the server forbids unknown fields on **write**, adding an *input* field is a breaking change for old servers, not for old clients |
40
+ | Schema version | Every response carries `schema_version`. Currently `"1.0"` (`core/schemas.py:21`) |
41
+ | Machine-readable errors | Every error body carries a stable `code` string from the taxonomy in §5 |
42
+
43
+ ### 1.1 Unknown fields are REJECTED, on read and on write
44
+
45
+ **CORRECTED 2026-09-22 (C-2).** This section previously claimed that consumers
46
+ "MUST tolerate unknown fields on read (forward compatibility)" and that only the
47
+ write direction was strict. **That was wrong, and the code is authoritative.**
48
+
49
+ `ResultEnvelope`, `HealthStatus`, `AnalysisRequest` and every other
50
+ contract-facing model in `core/schemas.py` sets `extra="forbid"` — **with exactly
51
+ one exception, `GeoMetadata`, which sets `extra="allow"`** (see below).
52
+ Pydantic applies `forbid` symmetrically: an unknown field in a request body is a
53
+ `422`, and an unknown field in a response body raised during `model_validate` is a
54
+ `ValidationError`. A client that parses a response through these models does
55
+ **not** get forward compatibility — it gets a hard failure the moment the server
56
+ emits a field it has never heard of.
57
+
58
+ > **The one exception, and why it is deliberate.** `GeoMetadata`
59
+ > (`core/schemas.py:120`) sets `extra="allow"` and is the **only** model in the
60
+ > codebase that does. It is the geospatial descriptor attached to
61
+ > `AssetMetadata.geo` and `SpecialistResult.geospatial`, so it **is** reachable in
62
+ > every `/v1/analyze` response. The reason is that a raster reader supplies
63
+ > whatever tags the source file carries, and forbidding unknown keys there would
64
+ > discard provenance a caller may need.
65
+ >
66
+ > **What this means for a client.** The strictness rule above holds for every
67
+ > shape *except* the contents of a `geospatial`/`geo` object. A strict validator
68
+ > will reject an unexpected top-level field, but must **not** reject an unexpected
69
+ > key inside `geospatial` — the server may legitimately add one without a version
70
+ > bump. Treat that sub-object as the single open surface in the contract.
71
+ >
72
+ > This exception was recorded in `docs/STEP7_BACKEND_CHAIN_REPORT.md` but had
73
+ > never reached this document, which is the one a client author reads. Recorded
74
+ > here by the STEP 8 audit as finding C-8.
75
+
76
+ The consequences, stated plainly because they are easy to get wrong:
77
+
78
+ - **Additive changes are not free.** Adding a field to a response breaks any
79
+ client that validates strictly. This is why §2.1's `modalities` field could
80
+ not simply be dropped into the capability entry without recording it here.
81
+ - **A version bump is required** when a field is added, not only when one is
82
+ removed. The old row in the table above said the opposite; the corrected row
83
+ says what the code does.
84
+ - **Clients should be written permissively even though the server is strict.**
85
+ That is a client-side robustness measure, not a server guarantee, and the
86
+ server must not be documented as if it provided one.
87
+
88
+ The direction of this correction matters: the *documentation* was the wrong
89
+ half, so the documentation was fixed and `extra="forbid"` was left alone.
90
+ Loosening the schema to match the prose would have replaced a clear failure with
91
+ a silently-ignored field, which is worse for a machine-readable contract.
92
+
93
+ ---
94
+
95
+ ## 2. Endpoints
96
+
97
+ The surface is **four** endpoints. The plan fixed three; the owner ruling of
98
+ 2026-09-22 added the fourth by choosing Option A for upload (§2.5).
99
+
100
+ | Method | Path | Purpose | Auth |
101
+ |---|---|---|---|
102
+ | `GET` | `/v1/health` | Liveness + which models are loaded | none |
103
+ | `GET` | `/v1/capabilities` | What this deployment can actually do right now | none |
104
+ | `POST` | `/v1/analyze` | Run one analysis request | none (see §7) |
105
+ | `POST` | `/v1/assets` | Upload one image out of band; returns an opaque handle | none (see §7, §2.5) |
106
+
107
+ ---
108
+
109
+ ### 2.1 `GET /v1/health`
110
+
111
+ Liveness probe. Cheap. **Must not** load a model, must not touch the GPU.
112
+
113
+ **Response `200`** — shape is `HealthStatus` (`core/schemas.py:392`). This is the
114
+ measured output of a deployment where the CROMA checkpoint is not shipped:
115
+
116
+ ```json
117
+ {
118
+ "status": "degraded",
119
+ "schema_version": "1.0",
120
+ "models": {
121
+ "caption": "not_requested",
122
+ "change": "not_requested",
123
+ "change_vqa": "not_requested",
124
+ "grounding": "not_requested",
125
+ "optical_sar": "absent",
126
+ "vqa": "not_requested"
127
+ },
128
+ "device": "cpu",
129
+ "gpu_available": false
130
+ }
131
+ ```
132
+
133
+ **Every capability the registry resolves appears in `models`**, and the set is
134
+ identical to `capabilities[].task` in §2.2 — the two endpoints are generated
135
+ from one source, so they cannot enumerate different capabilities. A key is never
136
+ absent; a capability that cannot be served is reported with a state, not by
137
+ omission.
138
+
139
+ | Field | Type | Notes |
140
+ |---|---|---|
141
+ | `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` |
142
+ | `schema_version` | `string` | Always present |
143
+ | `models` | `object<string,string>` | Per-capability state. Values are **strings, not booleans**, so a reason can be carried. See §2.3 for the vocabulary |
144
+ | `device` | `string \| null` | `"cpu"`, `"cuda"`, `"mps"`, or `null` if unknown |
145
+ | `gpu_available` | `boolean` | Whether a CUDA/MPS device was detected. `false` is normal on ZeroGPU Spaces until a request is executing |
146
+
147
+ > **`device` is a closed set, and `null` means the value was not understood.**
148
+ > **F-8, corrected here (2026-09-22).** This row has always published four
149
+ > legal values, but the reader accepted **any** string and echoed it into the
150
+ > field, so `SATQUERY_DEVICE=garbage` served `{"device": "garbage"}` — a value
151
+ > the frontend has no rendering for. The reader now casefolds and validates
152
+ > against the set above; anything unrecognised is served as `null`.
153
+ > `null` is deliberately **not** a silent `"cpu"`: reporting the CPU because the
154
+ > operator mistyped would be a false statement about the deployment, and it is
155
+ > the same mistake that F-7 fixed in a different variable.
156
+ > The case of the operator's input is also no longer significant: before this
157
+ > fix `SATQUERY_DEVICE=CUDA` resolved differently from `SATQUERY_DEVICE=cuda`,
158
+ > which let one payload claim `gpu_available: true` alongside `device: "CUDA"`.
159
+ >
160
+ > **Invariant the frontend may rely on:** if `device == "cuda"` then
161
+ > `gpu_available` is `true`. The converse does **not** hold — a GPU may exist
162
+ > while `device` is `"cpu"` (the operator chose it, or the config did).
163
+
164
+ **Important for the frontend:** `gpu_available: false` on a ZeroGPU Space is
165
+ **expected**, not an error. ZeroGPU allocates the GPU only for the duration of a
166
+ decorated call. Do not surface this as a fault.
167
+
168
+ This endpoint is answered **without loading any model and without importing
169
+ torch** — the device is resolved from configuration, not by probing the runtime.
170
+ A liveness probe that built the world would consume GPU quota to say "I am
171
+ alive".
172
+
173
+ ---
174
+
175
+ ### 2.2 `GET /v1/capabilities`
176
+
177
+ What the deployment can do **right now**, derived from actual artifact presence —
178
+ not from what the code could theoretically do.
179
+
180
+ **Every capability the registry resolves is listed**, including ones this
181
+ deployment cannot serve. A capability that cannot be served is reported
182
+ `available: false` with a reason, never omitted: omitting it would make it
183
+ invisible to the frontend, which cannot disable an affordance it was never told
184
+ about.
185
+
186
+ **Response `200`** — this is the measured output of a deployment where four of
187
+ the six capabilities lack their runtime dependencies and `optical_sar` lacks its
188
+ CROMA checkpoint:
189
+
190
+ ```json
191
+ {
192
+ "schema_version": "1.0",
193
+ "capabilities": [
194
+ {
195
+ "task": "change",
196
+ "available": true,
197
+ "reason": null,
198
+ "requires_pair": true,
199
+ "max_assets": 2
200
+ },
201
+ {
202
+ "task": "change_vqa",
203
+ "available": true,
204
+ "reason": null,
205
+ "requires_pair": true,
206
+ "max_assets": 2
207
+ },
208
+ {
209
+ "task": "optical_sar",
210
+ "available": false,
211
+ "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",
212
+ "requires_pair": true,
213
+ "max_assets": 2,
214
+ "modalities": ["optical", "sar"]
215
+ },
216
+ {
217
+ "task": "caption",
218
+ "available": true,
219
+ "reason": "the SmolVLM weights are fetched from the Hugging Face Hub on first use and no local checkpoint_path is configured in this deployment",
220
+ "requires_pair": false,
221
+ "max_assets": 1
222
+ }
223
+ ],
224
+ "deployment": {
225
+ "platform": "huggingface-spaces",
226
+ "zerogpu": true,
227
+ "lazy_load": true,
228
+ "cache_max_models": 1,
229
+ "torch_compile": false
230
+ }
231
+ }
232
+ ```
233
+
234
+ *(Abridged: the real response lists all six. `grounding` and `vqa` are omitted
235
+ here only to keep the example readable. The two reasons shown are verbatim --
236
+ note that `optical_sar`'s names **two** missing artifacts, because two are
237
+ required and both are absent.)*
238
+
239
+ | Field | Type | Notes |
240
+ |---|---|---|
241
+ | `capabilities[].task` | `string` | One of the `Task` enum values (§3.1) |
242
+ | `capabilities[].available` | `boolean` | Whether the task can be served on this deployment **right now** |
243
+ | `capabilities[].reason` | `string \| null` | **Required when `available` is `false`.** A bare `false` with no reason is not compliant |
244
+ | `capabilities[].modalities` | `string[]` | **Optional; present only for `optical_sar`.** Valid pairings for a modality-sensitive task |
245
+ | `capabilities[].requires_pair` | `boolean` | Whether two assets are required |
246
+ | `capabilities[].max_assets` | `integer` | Maximum assets accepted |
247
+ | `deployment.platform` | `string` | Deployment target, e.g. `"huggingface-spaces"` |
248
+ | `deployment.zerogpu` | `boolean` | Whether GPU work runs under ZeroGPU's per-call allocation |
249
+ | `deployment.lazy_load` | `boolean` | `true` means models load on first use; this is what makes `not_requested` the normal state (§2.3.1) |
250
+ | `deployment.cache_max_models` | `integer` | Resident-model cap. `1` means requests serialize — see the obligation below |
251
+ | `deployment.torch_compile` | `boolean` | 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 |
252
+
253
+ **A `reason` on an *available* capability is not a defect.** Three capabilities
254
+ above are `available: true` and still carry a reason — it reads
255
+ *"…fetched from the Hub on first use, no local checkpoint configured"*. That is
256
+ not an error; it is a disclosure that the first request will be slow and will
257
+ need egress. A frontend that treats a non-null `reason` as a failure will
258
+ mislay every cold start.
259
+
260
+ **Contract obligations:**
261
+ - The frontend **MUST** build its UI affordances from this response, not from a
262
+ hardcoded list. A capability that is `available: false` must be shown as
263
+ disabled **with its `reason` displayed** — never hidden, never silently
264
+ downgraded to a different task.
265
+ - The deployment block echoes `configs/deploy.yaml`. Note `cache_max_models: 1`:
266
+ at most one model is resident. Concurrent requests for different specialists
267
+ will evict each other, so **the frontend must not assume parallel throughput**.
268
+
269
+ ---
270
+
271
+ ### 2.3 Capability state vocabulary
272
+
273
+ Used in `GET /v1/health` → `models`, and consistent with `capabilities`.
274
+
275
+ **These five words are the complete permitted vocabulary.** They are the
276
+ contract's vocabulary and are **not** the registry's — see §2.3.1.
277
+
278
+ | Value | Meaning |
279
+ |---|---|
280
+ | `"loaded"` | Resident and ready |
281
+ | `"absent"` | The artifact is not present in this deployment. Permanent for this revision; not retryable |
282
+ | `"unavailable"` | Present but could not be loaded (corrupt, incompatible, dependency missing). **This is a defect**, distinct from `absent` |
283
+ | `"not_requested"` | Nothing has attempted to load it yet (normal with `lazy_load: true`) |
284
+ | `"evicted"` | Was loaded, was unloaded to make room (`cache_max_models: 1`) |
285
+
286
+ `absent` and `unavailable` **must not be conflated** in the UI. Absent means "this
287
+ build does not ship it"; unavailable means "this build ships it and it is broken".
288
+
289
+ ---
290
+
291
+ #### 2.3.1 Why there is a translation layer, and what it must never leak
292
+
293
+ **Added 2026-09-22 by owner ruling.** This is the section that explains the
294
+ otherwise-odd fact that the system has *two* capability vocabularies.
295
+
296
+ The registry — the component that resolves specialists — speaks a different
297
+ language from this contract.
298
+
299
+ **The adapter derives the contract state; it does not read a live registry
300
+ state.** This is the one fact about the layer that is easy to get backwards, so
301
+ it is stated first. `app/deployment.py` inspects the registry's declared **spec
302
+ table** and the **filesystem** and derives the contract state from what it
303
+ finds. It never calls `build()`/`build_all()`, because requirement 4
304
+ (`DEPLOYMENT_ARCHITECTURE.md` §3.3) forbids loading a model to answer a metadata
305
+ request. A live registry state is therefore *not observable* on this path, and
306
+ the registry's word is reconstructed from the contract state — not translated
307
+ into it.
308
+
309
+ That inversion has one visible consequence: **`loaded` is never emitted, and
310
+ neither is `degraded`.** A capability whose artifacts are all present and which
311
+ has not yet been asked for is reported `not_requested`, not `loaded`, because
312
+ "a model is resident" is a claim no process can honestly make without having
313
+ loaded it. The following table is exhaustive — it lists every state the adapter
314
+ can produce:
315
+
316
+ | Contract state | When it is emitted | `available` | Why |
317
+ |---|---|---|---|
318
+ | `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 (requirement 4) |
319
+ | `absent` | A required shipped artifact is not on disk in this deployment | `false` | Nothing is broken; the deployment does not ship it. The reason names the specific artifact |
320
+ | `unavailable` | Construction was attempted in this process and failed (defect path only) | `false` | Present but broken — a genuine defect, which the contract keeps distinct from `absent` |
321
+ | `evicted` | *(never emitted)* | — | A runtime model-cache fact. No static inspection can observe it, so the server never claims it |
322
+ | `loaded` | *(never emitted)* | — | See above. Note the vocabulary is closed, so a client must still be prepared to read it if a future revision emits it |
323
+
324
+ > **`available: true` and `models: "not_requested"` coexist by design, and that
325
+ > is not a contradiction.** The two fields answer different questions:
326
+ > `available` is "can this deployment serve this capability?" and `not_requested`
327
+ > is "has anything loaded it yet?". Under `lazy_load: true` the healthy answer to
328
+ > the second is *no, not yet* — for every capability, including ones that will
329
+ > work perfectly on the first request. A frontend that treats `not_requested` as
330
+ > a fault will mislabel a fully working deployment.
331
+
332
+ Two consequences the frontend must internalise:
333
+
334
+ 1. **`unavailable` means the opposite thing on each side of the layer.** To the
335
+ registry it is "no builder could be constructed" — a benign state that
336
+ includes simply not having the artifact. To this contract it is "present but
337
+ broken, therefore a defect". The same spelling, opposite severity. This is
338
+ precisely why the layer exists, and why the table above is the only
339
+ definition a client may rely on. Note the important corollary: the benign
340
+ registry meaning *does not* surface as `unavailable` here — a missing
341
+ artifact surfaces as `absent`, which is a different word and a different
342
+ remedy.
343
+ 2. **A capability is never omitted for being unservable.** It is reported
344
+ `available: false` with a reason. The registry resolves six capabilities
345
+ regardless of what this host can run.
346
+
347
+ The registry's own vocabulary is **internal** and is never served on any
348
+ endpoint. `/v1/health` and `/v1/capabilities` are the only sources a client
349
+ needs, and both are generated from the contract vocabulary above.
350
+
351
+ ---
352
+
353
+ ### 2.4 `POST /v1/analyze`
354
+
355
+ Run one analysis. This is the only endpoint that can consume GPU quota.
356
+
357
+ #### Request (JSON)
358
+
359
+ Shape is `AnalysisRequest` (`core/schemas.py:374`). `extra="forbid"`.
360
+
361
+ ```json
362
+ {
363
+ "assets": ["asset_0", "asset_1"],
364
+ "query": "How has the built-up area changed between these two dates?",
365
+ "force_task": "change_vqa",
366
+ "run_id": "9f2c1c0e-4a5b-4f5e-9a2c-1b3d4e5f6a7b"
367
+ }
368
+ ```
369
+
370
+ | Field | Type | Required | Notes |
371
+ |---|---|---|---|
372
+ | `assets` | `string[]` | **yes** | Minimum length 1. Values are **asset handles returned by the upload step** (§2.5), not base64 and not URLs |
373
+ | `query` | `string` | **yes** | Natural language. Empty string is permitted by the schema but will route to an `unsupported_query` error in practice |
374
+ | `force_task` | `string \| null` | no | One of the `Task` values (§3.1). Bypasses the intent router |
375
+ | `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**, so the client should record whichever value comes back |
376
+
377
+ #### Request (`multipart/form-data`) — the upload path
378
+
379
+ When images are uploaded directly, use:
380
+
381
+ ```
382
+ POST /v1/analyze
383
+ Content-Type: multipart/form-data
384
+ ```
385
+
386
+ | Part | Type | Notes |
387
+ |---|---|---|
388
+ | `assets` | file, repeatable | 1–2 image files. Field name repeats for the pair |
389
+ | `request` | text | A JSON string of the `AnalysisRequest` body with `assets` omitted |
390
+
391
+ > **Not yet implemented.** The multipart entry point is part of the gateway's
392
+ > contract but the reference implementation serves the JSON form only. See
393
+ > §8 for the status boundary. Build the frontend against the JSON form, which
394
+ > pairs with `POST /v1/assets`.
395
+
396
+ ---
397
+
398
+ ### 2.5 `POST /v1/assets` — **IMPLEMENTED** (Option A)
399
+
400
+ > **Superseded decision record.** The paragraphs below originally recorded this
401
+ > endpoint as an unresolved gap with two options. The owner ruling of
402
+ > 2026-09-22 chose **Option A** (out-of-band upload with opaque ephemeral
403
+ > handles). The endpoint is built and is part of the served surface. The
404
+ > original reasoning is retained verbatim underneath, because the reason the
405
+ > fourth endpoint exists at all is the argument in it.
406
+
407
+ `AnalyzeRequest.assets` is `list[str]` — asset *handles*, not bytes — and the
408
+ plan defines no upload endpoint. Those two facts cannot both hold without a
409
+ fourth endpoint, so there is one.
410
+
411
+ **Request.** `multipart/form-data` with exactly one part, the file. The
412
+ `Content-Type` of the part is the declared type.
413
+
414
+ **Response `201`.**
415
+
416
+ ```json
417
+ {
418
+ "asset_id": "asset_7c6f64a4a4c821e25d518467a1cc5d47",
419
+ "content_type": "image/png",
420
+ "bytes": 20481,
421
+ "expires_at": "2026-09-22T04:42:21.000Z"
422
+ }
423
+ ```
424
+
425
+ Three guarantees the frontend depends on, and the shape of each:
426
+
427
+ | Concern | Guarantee |
428
+ |---|---|
429
+ | **Opacity** | `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 |
430
+ | **Size limit** | A per-file byte cap, configurable per deployment (see §2.5.1). **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 (`gateway/app.py::_read_body_bounded`). The *Space* does the same: since the F-9 fix it refuses while reading via the shared `gateway/assets.py::read_body_bounded`, rather than buffering the body and leaving the cap to `store.put()`. **Both layers therefore refuse an over-limit body without holding it in full, and either one alone is sufficient** — a client that reaches the Space directly is covered, not only one that goes through the gateway. An over-limit upload gets `413` and **writes nothing** |
431
+
432
+ > **F-6, corrected here (2026-09-22).** This row previously read *"enforced on the
433
+ > received bytes — not on a declared `Content-Length`"*. That described the store
434
+ > correctly and the **gateway incorrectly**: at the gateway the cap was applied
435
+ > *only* to the header, because `policy.admit` runs before the body is read and
436
+ > the header is the only evidence it has. Measured through the real ASGI stack
437
+ > with the cap at 8 MiB and a 12 MiB body: a declared `Content-Length` drew `413`
438
+ > (peak 0.2 MiB, 0 bytes read) but an **omitted** one drew `502` with a peak of
439
+ > **13.9 MiB** — the whole body buffered past the cap. Allocation then tracked
440
+ > body size exactly with no ceiling (1/8/16/32/64 MiB in → 3.0/8.1/16.0/32.0/64.0
441
+ > MiB allocated). The gateway now enforces the cap while reading, and pinning
442
+ > tests assert both the refusal and that the boundary is still inclusive at
443
+ > exactly the cap.
444
+
445
+ > **F-7, corrected here (2026-09-22).** The cap is set by **one** variable,
446
+ > `SATQUERY_MAX_FILE_BYTES`, read by **both** layers — and until this fix each
447
+ > layer **parsed it separately**, so "one variable" did not mean "one value".
448
+ > Measured on four inputs (probe `probe_f7_cap_parsers.py`): `'abc'` and `'4e6'`
449
+ > made the gateway **raise at startup** while the Space **silently returned the
450
+ > 4 MiB default**; `'0'` and `'-1'` were **accepted** by the gateway while the
451
+ > Space rejected them only when the first upload arrived. Neither layer was right
452
+ > in both directions. Both now refuse an unparsable **or non-positive** value,
453
+ > naming the variable, and a cross-layer agreement test drives the whole matrix
454
+ > through both real parsers. A deployment whose cap is malformed no longer starts
455
+ > at all, at either layer, instead of quietly running on a limit nobody chose.
456
+ | **Content-type allowlist** | A **closed list of exactly five types**: `image/tiff` · `image/geotiff` · `image/png` · `image/jpeg` · `application/octet-stream`. A request with **no** declared type is **refused rather than defaulted** — defaulting is how a PDF reaches a raster reader. A disallowed type gets `415`. Media-type parameters are ignored, so `image/tiff; charset=binary` is accepted (`gateway/assets.py::_normalise_content_type`). **`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 |
457
+ | **Retries** | There is **no idempotency key**. A retry is a **new** upload that mints a **new** handle; the previous handle is not reused and is not revoked, it simply lapses on its TTL. A client that retries must therefore use the *latest* handle, and should expect the abandoned one to occupy a slot until it expires |
458
+
459
+ **Errors.** `413` over the size limit · `415` unsupported or absent content
460
+ type · `503` the asset store is not configured on this deployment · `400` for a
461
+ malformed body. All use the §5 envelope.
462
+
463
+ **Lifetime.** Handles expire on a TTL and are **refused on read** once lapsed —
464
+ a lapsed handle is rejected even if nothing has swept it, so a client never
465
+ succeeds by racing a cleanup job. Capacity is bounded, and **a live handle is
466
+ never evicted to make room**: when the store is full it refuses (`503`) rather
467
+ than invalidating a handle a client is about to use. A handle is single-use in
468
+ practice — consuming it in `/v1/analyze` does not consume it, so the same handle
469
+ may be analysed repeatedly until it expires.
470
+
471
+ **What `asset_id` is not.** It is not a path, and the response never discloses
472
+ one. The stored filename is derived from the **content type**, never from the
473
+ client's filename, so a client-supplied `../../` cannot influence where bytes
474
+ land.
475
+
476
+ ---
477
+
478
+ #### 2.5.1 Original decision record (retained)
479
+
480
+ The gap this section originally recorded was stated as **"NOT IN THE PLAN"**: the
481
+ plan fixes the surface at three endpoints, none of which accepts a file, yet
482
+ `AnalyzeRequest.assets` is `list[str]` of handles. Both cannot be true without a
483
+ fourth endpoint. The phrase is preserved here because it is the finding, and a
484
+ decision record that deletes the problem it solved is not a record.
485
+
486
+ | Option | Shape | Trade-off |
487
+ |---|---|---|
488
+ | **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 |
489
+ | **B. Inline multipart** — not chosen | `/v1/analyze` accepts `multipart/form-data` directly (§2.4) | One round trip. Couples upload and analysis; a retry re-uploads |
490
+
491
+ The three things the frontend needs — *a per-file size limit, a content-type
492
+ allowlist, and an idempotency story for retries* — are specified in the table
493
+ above. Note that "idempotency story" resolved to "there is none, a retry mints a
494
+ new handle", which is a decision rather than an omission: with no request key in
495
+ the contract, a deduplicating server would have to hash payloads, and a
496
+ content-hash handle is exactly the guessable identifier §2.5 forbids.
497
+
498
+ ---
499
+
500
+ #### Response `200`
501
+
502
+ Shape is `ResultEnvelope` (`core/schemas.py:383`).
503
+
504
+ ```json
505
+ {
506
+ "run_id": "9f2c1c0e-4a5b-4f5e-9a2c-1b3d4e5f6a7b",
507
+ "schema_version": "1.0",
508
+ "result": {
509
+ "task": "change_vqa",
510
+ "answer": "The built-up area increased...",
511
+ "labels": [],
512
+ "regions": [],
513
+ "boxes": [
514
+ {
515
+ "x1": 0.12, "y1": 0.34, "x2": 0.56, "y2": 0.78,
516
+ "label": "expanded built-up area",
517
+ "score": 0.81,
518
+ "coordinate_system": "normalized_0_1"
519
+ }
520
+ ],
521
+ "masks": [],
522
+ "change_map": null,
523
+ "evidence": [
524
+ {
525
+ "evidence_id": "ev_001",
526
+ "type": "change_map",
527
+ "score": 0.72,
528
+ "source_specialist": "change_vqa",
529
+ "coordinate_system": "normalized_0_1",
530
+ "coordinates": [0.12, 0.34, 0.56, 0.78],
531
+ "artifact_ref": null,
532
+ "payload": {}
533
+ }
534
+ ],
535
+ "confidence": {
536
+ "raw": 0.991,
537
+ "calibrated": 0.987,
538
+ "method": "temperature_scaling",
539
+ "components": {},
540
+ "degraded": false,
541
+ "degradation_reason": null
542
+ },
543
+ "geospatial": {},
544
+ "execution_trace": null,
545
+ "schema_version": "1.0",
546
+ "warnings": [],
547
+ "degraded": false
548
+ },
549
+ "trace": {
550
+ "run_id": "9f2c1c0e-4a5b-4f5e-9a2c-1b3d4e5f6a7b",
551
+ "task": "change_vqa",
552
+ "intent": null,
553
+ "query": "How has the built-up area changed?",
554
+ "modalities": ["optical"],
555
+ "workflow": [],
556
+ "steps": [],
557
+ "timings": {},
558
+ "selected_models": [],
559
+ "parameters": {},
560
+ "config_hash": "78f1e3700da15aa1",
561
+ "inputs": [],
562
+ "outputs": [],
563
+ "errors": [],
564
+ "fallbacks": [],
565
+ "contradiction": false,
566
+ "validation": {},
567
+ "confidence": null,
568
+ "started_at": "2026-09-22T04:12:21.000Z",
569
+ "finished_at": "2026-09-22T04:12:29.400Z",
570
+ "schema_version": "1.0"
571
+ }
572
+ }
573
+ ```
574
+
575
+ > `trace` above lists every field `ExecutionTrace` defines. Fields left `null` or
576
+ > empty here are genuinely optional, not omitted from the contract — the model
577
+ > uses defaults, so they will normally be **present** in a real response. The
578
+ > frontend should read only the ones it needs and tolerate the rest.
579
+
580
+ #### The fields the frontend must read correctly
581
+
582
+ | Field | Why it matters |
583
+ |---|---|
584
+ | `result.confidence.value` | **NOT a JSON field.** It is a Python `@property` on `ConfidenceBreakdown` and is **not serialised** (verified: `model_dump()` yields only `calibrated, components, degradation_reason, degraded, method, raw`). To get the number the user should see, read `calibrated` if it is non-null, otherwise `raw` |
585
+ | `result.confidence.method` | `"uncalibrated"` or `"temperature_scaling"`. See §4 |
586
+ | `result.confidence.degraded` / `degradation_reason` | Whether the confidence is trustworthy. **Display the reason verbatim when set** |
587
+ | `result.degraded` + `result.warnings` | The result is served but something was degraded. Warnings are for the operator, not the end user |
588
+ | `result.answer` | `""` for non-VQA tasks. Empty is valid |
589
+ | `result.boxes[].coordinate_system` | **Read this per box.** See §3.2 |
590
+ | `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` |
591
+ | `result.evidence[]` shape | Every evidence item carries: `evidence_id` (unique within a result), `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** — see "Artifact refs — `null` in v1, and why" above |
592
+ | `result.evidence[].type` | One of 11 `EvidenceType` values: `image_crop`, `tile`, `bounding_box`, `mask`, `change_map`, `optical_view`, `sar_view`, `joint_feature_region`, `statistic`, `geolocation`, `availability_mask` |
593
+ | `trace.steps[].state` | `ControllerState` — the pipeline stage. `detail` and `duration_ms` accompany it |
594
+ | `trace.steps` | Observable facts only. **Never chain-of-thought** (plan section 26). Safe to display |
595
+ | `trace.config_hash` | The frozen config identity. `78f1e3700da15aa1` for this revision |
596
+
597
+ #### Artifact refs — `null` in v1, and why
598
+
599
+ **Every `artifact_ref` and `change_map` in a v1 response is `null`.** This is a
600
+ deliberate contract, not a missing value.
601
+
602
+ F-16 (owner ruling 2026-09-23): **never expose filesystem paths.** The
603
+ specialists *do* render their artifacts — the change map and the optical/SAR
604
+ views are written server-side — but their location is an operator fact, not a
605
+ client-facing one. A response that carried the server's path would disclose the
606
+ deployment's directory layout to an unauthenticated caller, and nothing the
607
+ frontend can do requires it.
608
+
609
+ **No `artifact://` URI is fabricated in its place.** v1 has **no
610
+ artifact-serving endpoint**, so a URI would be a promise the service cannot
611
+ keep — strictly worse than `null`, because the frontend would build a link that
612
+ 404s. The earlier revision of this document showed
613
+ `"artifact_ref": "artifact://run/9f2c.../change_map.png"`; no production file
614
+ ever emitted one, which is exactly how the divergence survived. The example
615
+ above now shows the ruled shape.
616
+
617
+ What replaces the ref:
618
+
619
+ | Removed | Replaced by |
620
+ |---|---|
621
+ | `change_map` path | `null`, plus the change statistics in the CHANGE_MAP evidence's `payload` (`total_change_pixels`, `n_components_kept`, `threshold`) |
622
+ | view `artifact_ref` path | `null`, plus `payload.rendered` / `payload.retrievable` / `payload.retrieval` |
623
+ | — | an explicit `warnings[]` entry saying the artifact is **NOT retrievable** |
624
+
625
+ The frontend must therefore treat `artifact_ref` as **always `null` in v1** and
626
+ read the payload statistics instead. A non-null ref appearing here in future
627
+ means an artifact-serving endpoint was added, and this section changes with it.
628
+
629
+ ---
630
+
631
+ ## 3. Enumerations
632
+
633
+ These are fixed by `core/schemas.py`. The frontend must treat them as closed sets
634
+ for display, but **must not assume they will never grow** — an unknown value
635
+ should render as its raw string, not crash.
636
+
637
+ ### 3.1 `Task` (`core/schemas.py:35`)
638
+
639
+ Seven values. Note `unsupported` is a `Task`, not an error — it is how "I do not
640
+ know what you asked" is represented.
641
+
642
+ | Value | Meaning | Assets |
643
+ |---|---|---|
644
+ | `vqa` | Answer a question about one image | 1 |
645
+ | `caption` | Describe the image | 1 |
646
+ | `grounding` | Locate a described object | 1 |
647
+ | `change` | Detect change between two dates | 2 |
648
+ | `optical_sar` | Fuse optical and SAR | 2 |
649
+ | `change_vqa` | Answer a question about the change | 2 |
650
+ | `unsupported` | The router could not map the request to a specialist | 0 |
651
+
652
+ ### 3.2 Coordinate systems
653
+
654
+ `CoordinateSystem` is an explicit enum on every spatial field. **This is a
655
+ correctness-critical distinction** and a documented source of silent bugs.
656
+
657
+ | Value | Meaning | Rendering |
658
+ |---|---|---|
659
+ | `normalized_0_1` | `[0, 1]`, origin **top-left** | Multiply by image width/height |
660
+ | `pixel` | Absolute pixel coordinates | Use directly |
661
+ | `geo` | CRS coordinates (usually EPSG:4326) | Requires a map, not a 2-D canvas |
662
+
663
+ VRSBench annotations arrive normalised to **0–100**, not 0–1;
664
+ `evaluation.metrics.grounding.benchmark_to_normalized` performs the conversion.
665
+ **The frontend must read the `coordinate_system` field on each `Box`/`Region` and
666
+ must not assume one convention.** A box drawn with the wrong assumption lands in
667
+ plausible-looking wrong places.
668
+
669
+ > The raw enum values are the exact strings above. Earlier drafts of this document
670
+ > used shorthand (`normalized`, `geographic`); those are **wrong** and would fail
671
+ > schema validation, since `extra="forbid"` and the enum is closed.
672
+
673
+ ### 3.3 `Modality` (`core/schemas.py:50`)
674
+
675
+ | Value | Meaning |
676
+ |---|---|
677
+ | `optical` | Optical only |
678
+ | `sar` | SAR only |
679
+ | `optical_sar` | Both fused |
680
+ | `unknown` | Undetermined |
681
+
682
+ ---
683
+
684
+ ## 4. The confidence contract
685
+
686
+ This is the subtlest part of the API and the easiest to mis-render.
687
+
688
+ `ConfidenceBreakdown` (`core/schemas.py`) has:
689
+
690
+ | Field | Meaning |
691
+ |---|---|
692
+ | `raw` | The uncalibrated score |
693
+ | `calibrated` | The post-calibration score, or `null` |
694
+ | `method` | `"uncalibrated"` or `"temperature_scaling"` |
695
+ | `components` | A `string -> float` map of the individual signals that fed the confidence. May be empty. **Diagnostic only** — do not compute a confidence from it |
696
+ | `degraded` | Whether this confidence should be trusted |
697
+ | `degradation_reason` | Why, when `degraded` is `true` |
698
+
699
+ **Rules the frontend MUST follow:**
700
+
701
+ 1. Display `calibrated` when it is not `null`; otherwise display `raw`.
702
+ 2. Display `method` next to the value. `temperature_scaling` means a fitted
703
+ correction was applied; `uncalibrated` means it was not.
704
+ 3. **Never present a confidence as a percentage without its method.** A raw
705
+ 0.99 and a calibrated 0.99 do not mean the same thing.
706
+ 4. When `degraded` is `true`, show `degradation_reason`. Confidence that is
707
+ degraded is not a quality signal.
708
+
709
+ > **Measured caveat, recorded honestly.** The R-02 calibration fit
710
+ > (`artifacts/calibration_v001.json`, `T = 0.9772731820958189`, 16,441 Val rows)
711
+ > found that the raw softmax was **already near-calibrated** (ECE 0.013755) and
712
+ > that temperature scaling made ECE very slightly **worse** (0.014929) while
713
+ > improving NLL marginally (0.689741 → 0.689631). The frontend must not imply
714
+ > that `temperature_scaling` is inherently "more accurate" than `uncalibrated`.
715
+
716
+ ---
717
+
718
+ ## 5. Error contract
719
+
720
+ Every non-2xx response body has this shape:
721
+
722
+ ```json
723
+ {
724
+ "error": {
725
+ "code": "pair_misaligned",
726
+ "message": "The images are not sufficiently co-registered for spatial analysis.",
727
+ "detail": "RMSE 4.21 px exceeds the 2.0 px budget",
728
+ "recoverable": false,
729
+ "request_id": "req_01H...",
730
+ "run_id": "9f2c1c0e-..."
731
+ }
732
+ }
733
+ ```
734
+
735
+ `code` is **stable** and comes from `core/errors.py`. `message` is
736
+ operator-safe (`SatQueryError.user_message`). `detail` is technical and may be
737
+ absent.
738
+
739
+ ### 5.1 HTTP status mapping
740
+
741
+ | Status | When | `recoverable` |
742
+ |---|---|---|
743
+ | `400` | Malformed JSON, missing required field, or a malformed upload body | `false` |
744
+ | `404` | The path is not an endpoint at all (`routing_error`) | `false` |
745
+ | `405` | The path exists but not for this method (`routing_error`). `GET /v1/assets` is the common case: §2.5 defines it for `POST` only | `false` |
746
+ | `413` | Upload exceeds the per-file size limit | `false` |
747
+ | `415` | Upload's content type is absent or not on the allowlist | `false` |
748
+ | `422` | Schema violation (unknown field with `extra="forbid"`, wrong enum value, `assets` empty) | `false` |
749
+ | `429` | Rate limited. Honours `Retry-After` | `true` |
750
+ | `500` | Unexpected internal failure | `false` |
751
+ | `503` | A required model is `absent` or `unavailable`; or GPU quota exhausted; or the asset store is unconfigured or full | depends |
752
+ | `504` | The specialist exceeded its budget (`specialist_timeout`) | `true` |
753
+
754
+ `413` and `415` are upload-only (`POST /v1/assets`). A `415` is the expected
755
+ answer to an upload with no declared `Content-Type` — the server refuses rather
756
+ than guessing (§2.5).
757
+
758
+ **`404` and `405` carry this same envelope**, which is worth stating because they
759
+ are the two statuses a proxy framework raises before any handler runs. A client
760
+ should therefore not special-case them: parse `error.code` as usual. This was
761
+ made true on 2026-09-22 (the gateway previously returned the framework's own
762
+ `{"detail": "Not Found"}` for both, which broke any client that assumed §5);
763
+ `docs/STEP8_FINAL_CONFORMANCE_AUDIT.md` records the finding as F-3.
764
+
765
+ **A trailing slash is a `307`, not an error — and this is a real footgun.**
766
+ Starlette's default `redirect_slashes` behaviour applies: `GET /v1/analyze/`
767
+ answers `307` with `Location: http://<gateway-host>/v1/analyze`. Two consequences
768
+ a client must handle, both verified against the running app on 2026-09-22:
769
+
770
+ * The `Location` is built from the gateway's own host, **not** from the client's
771
+ request URL, so a redirect followed naively after a `POST` may not land where
772
+ the caller expects. Do not rely on it.
773
+ * A `307` preserves the method and body, so a `POST /v1/analyze/` will re-send
774
+ the body to `/v1/analyze` — which is fine, but it is a second request against
775
+ the rate limiter, and `/v1/analyze` is a `COSTLY_ROUTE`.
776
+
777
+ **Use exact paths with no trailing slash.** This is also why the runbook's
778
+ `SATQUERY_SPACE_URL` is normalised with a trailing-slash strip
779
+ (`docs/BACKEND_DEPLOYMENT_RUNBOOK.md` §4.1.1).
780
+
781
+ ### 5.2 The complete `code` taxonomy
782
+
783
+ From `core/errors.py`. The frontend should map these to user-facing copy; the
784
+ `user_message` field is a safe default.
785
+
786
+ | `code` | Meaning | Suggested UX |
787
+ |---|---|---|
788
+ | `satquery_error` | **Base class** — the fallback when a more specific code does not apply | Generic failure. Treat an unexpected occurrence as a defect |
789
+ | `input_error` | The uploaded input could not be read | Ask the user to re-upload |
790
+ | `raster_read_error` | Not a readable TIFF/GeoTIFF | "This file is not a readable GeoTIFF" |
791
+ | `missing_crs` | No coordinate reference system | "This image has no georeferencing" |
792
+ | `unsupported_bands` | Band layout unsupported | Explain expected bands |
793
+ | `oversized_image` | Exceeds the pixel budget | Offer downsampling |
794
+ | `pair_incompatible` | The two images do not match | Prompt for a better pair |
795
+ | `pair_misaligned` | Not co-registered | Explain alignment requirement |
796
+ | `temporal_pair_invalid` | Two distinct acquisitions required | Ask for a second date |
797
+ | `routing_error` | Request could not be interpreted | Offer `force_task` |
798
+ | `unsupported_query` | No specialist supports this | Show the capability list |
799
+ | `invalid_request` | Inputs do not support the task | Suggest a valid task |
800
+ | `workflow_plan_error` | Workflow could not be planned | Retry; report if persistent |
801
+ | `specialist_error` | A specialist failed | Generic failure |
802
+ | `model_load_error` | A model could not be loaded | **Defect** — surface it |
803
+ | `model_unavailable` | Model not available in this environment | Disable the capability |
804
+ | `out_of_memory` | OOM; retry at reduced resolution | Suggest a smaller image |
805
+ | `specialist_timeout` | Processing timed out | Offer retry |
806
+ | `schema_validation_error` | The system produced a malformed result | **Defect** — always report |
807
+ | `coordinate_error` | Invalid spatial coordinates | **Defect** |
808
+ | `confidence_range_error` | Confidence out of range | **Defect** |
809
+ | `leakage_violation` | A data isolation rule was violated | **Defect** — never user-facing |
810
+ | `benchmark_freeze_error` | The benchmark is not frozen | Evaluation-only |
811
+
812
+ **Render `user_message` as the default and override specific codes with better
813
+ copy.** Do not invent a mapping from `detail` — it is not stable.
814
+
815
+ ### 5.3 Gateway-origin codes (a separate set from §5.2)
816
+
817
+ There is exactly **one** code a client can receive that is *not* in the table
818
+ above, and it does not come from `core/errors.py`:
819
+
820
+ | `code` | Meaning | Suggested UX |
821
+ |---|---|---|
822
+ | `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 — see §7 |
823
+
824
+ Why it is a separate set rather than a §5.2 row: `tests/unit/test_gateway_responsibilities.py`
825
+ asserts that §5.2 and `core/errors.py` are in **exact one-to-one correspondence** (23
826
+ codes), and `tests/unit/test_gateway_policy.py` asserts that a gateway-origin code
827
+ may **never** shadow a taxonomy code. Both hold only if the two sets stay
828
+ disjoint — so `rate_limited` is documented here, beside the taxonomy rather than
829
+ inside it, and the correspondence test keeps its meaning.
830
+
831
+ The gateway mints this code and only for errors that **originate in the
832
+ gateway**; a code arriving from the Space is never replaced. `rate_limited` is
833
+ declared in `gateway/policy.py` (`GATEWAY_ORIGIN_CODES`) because §5.1 maps HTTP
834
+ `429` to "Rate limited" while the `core/errors.py` taxonomy — which covers the
835
+ *analysis* pipeline, not the proxy — assigns that status no code. Recorded here
836
+ on 2026-09-22, after a check of this document found the code reachable by every
837
+ throttled caller yet absent from every client-facing table.
838
+ `docs/PHASE19_FINAL_HARDENING.md` is the implementation record; this is the
839
+ client-facing one.
840
+
841
+ ---
842
+
843
+ ## 6. Latency, quotas and the realities of this deployment
844
+
845
+ | Constraint | Value | Source |
846
+ |---|---|---|
847
+ | ZeroGPU free tier | 5 GPU-minutes/day | plan section 48; `configs/deploy.yaml` |
848
+ | Declared GPU durations | vqa 20 s · grounding 45 s · change 30 s · optical_sar 45 s | `configs/deploy.yaml` |
849
+ | Resident models | **1** (`cache_max_models: 1`) | `configs/deploy.yaml` |
850
+ | `torch.compile` | **Disabled** — ZeroGPU does not support it (finding C-8) | `configs/deploy.yaml` |
851
+ | Lazy loading | Enabled — first request for a capability pays a cold start | `configs/deploy.yaml` |
852
+ | Server budget | `agent.timeout_seconds` | `configs/base.yaml` |
853
+
854
+ **Frontend obligations:**
855
+
856
+ 1. **Show a progress state.** A cold start can take tens of seconds. There is no
857
+ streaming API in v1; the client sends one request and waits.
858
+ 2. **Do not poll `/v1/health` aggressively.** Every `/v1/analyze` costs GPU
859
+ quota; health checks cost CPU. Polling health in a loop is fine; retrying
860
+ analyze in a loop is not.
861
+ 3. **Serialize requests.** With `cache_max_models: 1`, two concurrent analyses
862
+ for different tasks will evict each other and make both slower. If the UI
863
+ allows a queue, process it one at a time.
864
+ 4. **Handle `429` and `503` as normal states**, not as bugs. Quota exhaustion is
865
+ an expected condition on the free tier.
866
+
867
+ ---
868
+
869
+ ## 7. Authentication
870
+
871
+ **There is no authentication in v1.** This is a recorded boundary, not an
872
+ oversight.
873
+
874
+ Plan section 74 (Production Readiness Boundary) explicitly excludes auth,
875
+ multi-tenancy, distributed queues and autoscaling from scope. Consequently:
876
+
877
+ - The API must **not** be exposed on the open internet without a gateway-imposed
878
+ control. The intended control is the Railway gateway (§`docs/DEPLOYMENT_ARCHITECTURE.md`).
879
+ - Any credentials (HF token, gateway allowlist) live **server-side only** and are
880
+ never sent to the browser.
881
+ - The frontend **must not** embed an HF token, an API key, or any secret. It
882
+ talks only to the gateway.
883
+
884
+ **Do not build a login screen.** There is no auth to log into.
885
+
886
+ ### 7.1 CORS
887
+
888
+ The gateway sets CORS explicitly to the deployed frontend origin. It does not use
889
+ a wildcard. A preflight `OPTIONS` is answered by the gateway, not by the HF Space.
890
+
891
+ ---
892
+
893
+ ## 8. Status of this contract
894
+
895
+ | Element | Status |
896
+ |---|---|
897
+ | 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 (§2.5) |
898
+ | Request/response shapes | **Existing and tested** — `core/schemas.py` |
899
+ | Error taxonomy and `code` values | **Existing and tested** — `core/errors.py` |
900
+ | Error envelope (`{"error": {...}}`) | **Specified here.** The gateway must produce it; the Space's own errors are translated by the gateway |
901
+ | `POST /v1/assets` | **Implemented**, Option A, §2.5. Upload against it |
902
+ | Multipart upload into `/v1/analyze` | **Not implemented**, and not chosen — Option B was rejected, §2.5.1 |
903
+ | How the contract is **verified** | `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 |
904
+ | Authentication | **Deliberately absent** (plan section 74) |
905
+ | Streaming / progress | **Not in v1** |
906
+ | Rate-limit values | **Not specified by the plan.** The gateway must choose them; ask the maintainer |
907
+ | Asset TTL, size cap and content-type allowlist values | **Deployment configuration**, not contract constants. The *shape* of each guarantee is fixed (§2.5); the *value* is read from the environment per deployment |
908
+
909
+ **Nothing in the "Status" column above may be treated as settled if it says
910
+ "Not in v1", "Not implemented" or "Not specified by the plan"** — unless the row
911
+ also names a decision that closed it. Those are gaps this document surfaces
912
+ rather than fills.
913
+
914
+ ---
915
+
916
+ ## 9. Minimal frontend integration checklist
917
+
918
+ 1. `GET /v1/health` on load → show service state, including `degraded`.
919
+ 2. `GET /v1/capabilities` → build affordances from the response. Disable
920
+ unavailable tasks **with their reason shown**.
921
+ 3. For each analysis: (a) `POST /v1/assets` per image → collect `asset_id`
922
+ values; (b) `POST /v1/analyze` with `assets`, `query`, optional `force_task`.
923
+ 4. Render `result.answer`, then `confidence` per §4, then `result.warnings`
924
+ and `confidence.degradation_reason` if present.
925
+ 5. Draw `boxes`/`regions` using **each item's own `coordinate_system`**.
926
+ 6. Map errors per §5. Default to `user_message`; special-case the codes flagged
927
+ as defects so they are reported rather than swallowed.
928
+ 7. Serialize analyses. Do not retry `429`/`503` in a tight loop.
929
+ 8. Never embed a secret. Never build a login screen.