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
release: add docs/API_CONTRACT.md
Browse files- 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.
|