The REST surface is a thin client of the SDK: a route parses input, calls one service, and shapes the output. This document covers the part of the contract that is not any single endpoint — what a failure looks like, and how to read one.
The routes themselves are described by openapi.json at the repo root, which
is generated (uv run python scripts/export_openapi.py) and diffed in CI. Never hand-edit it.
For a worked external client, examples/http_end_to_end.py starts
visionset server on a free port and drives the whole cycle — upload, launch-and-poll ingest,
annotate, promote, publish, verify, export — with urllib and a bearer token, and nothing else. It
is deliberately dependency-free: a contract only a smart client can drive is not really a contract.
Every endpoint except /health requires a workspace API token:
Authorization: Bearer vst_hK3n...
Missing, malformed, unknown and revoked are one identical 401 with a WWW-Authenticate: Bearer challenge — deliberately indistinguishable, so a client cannot use the response to probe
which credentials exist. Tokens come from visionset token create; the server serves the single
workspace named by VISIONSET_WORKSPACE, and one pointed at something else answers 500
NOT_A_WORKSPACE. See auth.md for the whole picture, including how to build a
protected route.
Decided once, by the project and schema endpoints, and inherited by every endpoint after them.
Paths. Plural collection nouns, and a sub-resource nested under whatever owns it:
POST /projects
GET /projects
GET /projects/{project_id}
PATCH /projects/{project_id}
DELETE /projects/{project_id}
GET /projects/{project_id}/stats everything ingested
GET /projects/{project_id}/assets paged, every asset
GET /projects/{project_id}/schema the version in force
POST /projects/{project_id}/schema/versions
GET /projects/{project_id}/schema/versions
GET /projects/{project_id}/schema/versions/{version}
GET /projects/{project_id}/schema/compare ?from=&to=
POST /projects/{project_id}/sources/images multipart
POST /projects/{project_id}/sources/video multipart
GET /projects/{project_id}/sources
GET /sources/{source_id}
POST /sources/{source_id}/ingest-jobs launch
GET /sources/{source_id}/ingest-jobs
GET /ingest-jobs/{job_id} poll
POST /ingest-jobs/{job_id}/resume
GET /projects/{project_id}/assets/{asset_id}
GET /projects/{project_id}/assets/{asset_id}/content bytes
GET /projects/{project_id}/assets/{asset_id}/thumbnail bytes
GET /projects/{project_id}/batches
GET /batches/{batch_id}
POST /batches/{batch_id}/approve with a partition spec
POST /batches/{batch_id}/start
POST /batches/{batch_id}/repin ?allow_destructive=
POST /batches/{batch_id}/complete
GET /batches/{batch_id}/jobs
GET /batches/{batch_id}/assets paged
POST /batches/{batch_id}/assets draft only
DELETE /batches/{batch_id}/assets?id=&id= draft only
GET /jobs/{job_id}
GET /jobs/{job_id}/progress
POST /jobs/{job_id}/start
POST /jobs/{job_id}/complete
GET /jobs/{job_id}/next the next n waiting assets
PUT /jobs/{job_id}/assets/{asset_id}/progress
GET /jobs/{job_id}/assets/{asset_id}/annotations
POST /jobs/{job_id}/annotations bulk, all-or-nothing
PATCH /jobs/{job_id}/annotations bulk, all-or-nothing
DELETE /jobs/{job_id}/annotations ?id=&id=
GET /projects/{project_id}/dataset the one trunk
GET /datasets/{dataset_id}
GET /datasets/{dataset_id}/stats
GET /datasets/{dataset_id}/assets paged
DELETE /datasets/{dataset_id}/assets/{asset_id} curation, not deletion
GET /datasets/{dataset_id}/changes
POST /batches/{batch_id}/promote the one gate into the trunk
POST /datasets/{dataset_id}/releases
GET /datasets/{dataset_id}/releases
GET /releases/{release_id}
GET /releases/{release_id}/manifest bytes
GET /releases/{release_id}/verify
GET /releases/{release_id}/assignment
GET /releases/{release_id}/export-compatibility ?format=
POST /releases/{release_id}/export ?format=&allow_lossy=, bytes
GET /formats
A project's dataset is reached at a singular path, because the relation is 1:1 and there is
no collection to list. POST /batches/{id}/promote sits under the batch rather than the dataset
because DatasetService.promote takes a batch id and derives everything else from it — a
dataset_id in front would be a segment no service ever checks, and a path parameter nobody
validates is a lie a client will eventually rely on.
The active schema is the collection's parent, not a member of it, because "in force" is a property of the schema rather than a version number a client could guess.
There are three asset listings, and they window different things.
GET /projects/{id}/assets is every asset the project holds;
GET /batches/{id}/assets is one work unit's, in membership order;
GET /datasets/{id}/assets is the curated trunk's. A project page reads the first, a gallery
the second, a release the third.
The project listing is ordered by arrival, newest first, which #216 is what made possible:
until Asset.ingested_at existed nothing recorded when an asset arrived, and the listing was
deterministic but arbitrary. A whole ingest run shares one timestamp, so within a run the
order falls through to the one that means something — grouped by source, then by frame index
within a clip, then by path, then by id, so a clip's frames come back in order and a directory's
stills in filename order, and two calls can never disagree.
An asset with no recorded arrival sorts last. That is every asset ingested before v0.1.0,
and it cannot be backfilled — the information exists nowhere, and Source.registered_at is not
the proxy it looks like, because registration is idempotent on (kind, path, extraction_fps)
and is never rewritten. Sorting them last is the only reading that degrades quietly: treating
the missing value as the epoch invents a date, and treating it as now would pin the oldest
rows in the product to the top of a "recent" list forever. A workspace that has ingested nothing
since upgrading therefore looks exactly as it did before.
ingested_at is on every asset payload, and null still means unknown. Until #283 the
field decided the project listing's order and reached no client at all — the only thing that
crossed the boundary was the project-level aggregate, ProjectStats.last_ingest_at. It is now
on AssetOut, and therefore on BatchAssetOut, which inherits it. A client deriving an age
from it renders three states rather than two: a moment, and unknown, which is neither a
moment nor zero. The batch view's header is the first caller.
There are two stats endpoints, and they disagree on purpose.
GET /projects/{id}/stats counts every asset ingested into the project, whatever batch it
landed in; GET /datasets/{id}/stats counts the curated trunk, which an asset reaches only by
being promoted out of a completed batch. A project mid-annotation has assets in the first and
none in the second, and both numbers are true — they answer "what does this project hold?" and
"what would I train on?", which are different questions. Neither is derivable from the other, so
a client showing a project page reads the first and a client shaping a release reads the second.
Three smaller rules the project's stats carry: class_count is what the active schema version
declares, so a project that has authored an ontology and labeled nothing still reports its
classes, while classes lists only the ones somebody has used; annotated_pct is 0 for
a project with no assets, never null and never an error; and last_ingest_at is the newest
Asset.ingested_at in the project, null when unknown. Null there means unknown, not
never: a project whose assets all predate v0.1.0 reads null, and so does an empty one, and the
two are deliberately not distinguished because no caller can act differently on them. A count
has an honest identity element and a date does not, which is why this one is not defaulted the
way annotated_pct is.
A collection hangs off whatever owns it; an individually addressable resource does not.
A source belongs to one project, so listing and creating happen under it — but a source has an
id of its own, and nesting /projects/{p}/sources/{s}/ingest-jobs/{j} would put four segments
in front of a job that one id already identifies. A schema version has no such id, which is why
it stays nested all the way down.
Ids are UUIDs, canonical hyphenated form, in the path. One deliberate exception: a schema
version is an integer 1..N, because that is the handle the domain itself uses — an annotation
records schema_version, and a batch pins one at approval. A malformed UUID never reaches a
service, so it is a 422 VALIDATION_ERROR and not a 404: the request could not have named
anything.
Collections answer with an envelope, never a bare array:
{ "items": [ { "id": "…", "name": "road-signs", "description": null } ], "total": 1 }An array cannot grow a field without breaking every client that parsed it. total means
matching the query rather than in this page, which is exactly what let paging arrive on one
route without moving the shape everything else already spoke. An empty collection is
{"items": [], "total": 0} and a 200, never a 404.
Paging is on the two large collections and nowhere else — GET /batches/{id}/assets and
GET /datasets/{id}/assets, the batch that an ingest can fill with fifty thousand frames and
the trunk that accumulates every batch a project ever completed. It bounds the response, not
the read. The kernel has no windowed read, so limit and offset slice a list that was fetched
whole — worth doing, because a batch of fifty thousand frames must not be sent to a gallery in
one body, but not a cheap query and not advertised as one. Every other collection is small by
construction and gets these parameters when a caller shows up rather than in advance. Because
total is the size of the whole collection, a client pages until it has seen total items; an
offset past the end is an empty page and a 200. When the read itself starts to cost, the fix is
a windowed method on the persistence port and none of this contract moves.
Gates are query parameters; bodies carry content. Destroying data needs ?confirm=true, and
narrowing a schema needs ?allow_destructive=true. Neither is a body field, so recovering from
the 409 is resending the identical request with one extra parameter. The route does not
pre-check either one: the flag goes to the SDK and the SDK's refusal is what carries
CONFIRMATION_REQUIRED or DESTRUCTIVE_SCHEMA_CHANGE.
A stateful resource declares what it allows. BatchOut, JobOut and BatchAssetOut each
carry allowed_actions — a list of names, closed and published in the spec as BatchAction,
JobAction and AssetAction.
GET /batches/{id} → { "state": "in_annotation",
"allowed_actions": ["complete", "repin", "delete"], … }
A capability nothing can perform is withdrawn, not declared. The rule above cuts both ways:
if a client must render what the wire declares, the wire may only declare what some surface can
actually do. BatchAction.DELETE was withdrawn for exactly that reason in #331 — the rule and
the service method were real, the route was not — and came back in #376 together with
DELETE /batches/{id}, its MCP tool and its controls, which is the condition the withdrawal
set. tests/architecture/test_capability_reachability.py is what now measures it: every member
of BatchAction is resolved against the published paths and the MCP tool listing, computed from
the enum rather than from a list somebody maintains.
What a resource is is a second declaration, and not the same question. ConnectionOut
carries capabilities beside its allowed_actions: an action is something you may do to the
connection and is decided by its state, a capability is what its model answers and is decided
by the weights. Offering a tool needs both — a connection being ready says its files are here,
not that they are the right kind of model — and either list may be empty without the other being.
GET /inference/connections/{id} → { "setup_state": "ready",
"allowed_actions": ["download_weights", …],
"capabilities": ["point_suggest"], … }
An empty capabilities is not a refusal to act on: the server judges every request on its own
either way. It says only that nothing can yet rely on this connection for a particular tool —
because its weights never arrived, because its config declared a model type this build has no
adapter for, or because it is an http connection, which declares nothing until the remote
contract says how an endpoint states what it can do.
A client renders these; it never computes them. Re-deriving the rules from state and
progress is what the browser used to do, and its copy drifted by dropping the batch-state
dimension — which is why skipping a frame was offered on a batch the kernel refused every write
into. If an action you need is missing, the fix is in the projection, never a second copy of the
rule.
The declarations come from kernel/domain/capabilities.py, which reads the same transition
tables and named sets the services enforce with;
tests/kernel/test_capabilities.py drives both halves over a real workspace for every reachable
state, so the two cannot move apart.
Two of them are worth reading precisely:
completeon a batch is declared from the transition table alone, so it can still answer 409BATCH_NOT_COMPLETE: completion is derived from the jobs, which is a read the projection does not make. Read it as this batch is at the point where completing is the next move. A job'scompletecarries no such caveat — a job ships its own per-asset tally, so theSETTLED_PROGRESScondition is applied.annotateon a batch asset is the right to write labels, not a progress move: it is declared exactly whenPOST /jobs/{id}/annotationswill be accepted. An annotator deciding whether to open in edit mode should read this rather than infer it.
An empty list is normal and means what it says. Every asset of a completed batch declares
nothing, because nothing may be written into a batch that has closed.
Statuses. 201 with the created resource in the body; 200 for a read or an update; 204 with an empty body for a delete. And 202 when the work has not happened yet — see below.
A long operation is launched and then polled. Ingest is the first one, and the shape it set is the one every later long operation uses:
POST /sources/{id}/ingest-jobs → 202 Accepted
Location: /ingest-jobs/{job_id}
{ "id": …, "state": "pending", "processed": 0, … }
GET /ingest-jobs/{job_id} → 200 { "state": "running", "processed": 12, … }
GET /ingest-jobs/{job_id} → 200 { "state": "completed", "batch_id": …, … }
Export follows the same shape, over the generic surface #328 added:
POST /releases/{id}/export?format=yolo → 202 Accepted
Location: /background-jobs/{job_id}
GET /background-jobs/{job_id} → 200 { "state": "running", "processed": 12, … }
GET /background-jobs/{job_id}/artifact → 200 application/zip
The two surfaces are separate because they describe different things. An ingest job knows what it
is about — a source, a batch — and publishes those as fields a client can navigate. A background
job is about whatever its payload says, so it publishes type and result instead. What they
share is the progress shape, deliberately: processed, total, failures and error mean the
same thing on both, so a progress bar written against one renders the other unchanged.
There is no POST /background-jobs. What work means belongs to the resource it is about, so
every launch is on that resource. A generic route taking a type and a payload would be a
remote-code surface with a token in front of it, and every payload shape would become public the
day it shipped.
202, not 201: the row exists, the work does not. The row is what makes the id worth handing
back — it is written and committed before the response is sent, so the first poll always finds
something. That is also why anything the request can refuse is refused synchronously: an
unknown source is a 404 here rather than a 202 pointing at a job nobody wrote, and resuming a
completed run is a 409 here rather than a background no-op a client could not distinguish from
a redo. Everything that goes wrong after the launch is reported on the job — error for the
one fatal cause, failures for the per-item report — because by then there is no request left
to answer.
Uploads are multipart, and the only non-JSON request shape. Registering a source means
sending the bytes: one files part per image, or one file part plus an extraction_fps field
for a clip. VisionSet sets no size limit of its own — parts are spooled to disk past 1 MiB
and streamed from there, so memory does not grow with the file — which means the real ceilings
are your reverse proxy's (client_max_body_size in nginx) and free disk. Uploaded bytes are
staged under <workspace>/uploads/<digest>/ and, like blobs, are never deleted: a workspace
grows with what was offered to it, not only with what it kept. Re-uploading identical files
under identical names is free — it stages to the same path and returns the same source.
Bytes are streamed, and their URLs are immutable. Four responses are not JSON: an asset's content and its thumbnail, a release's manifest, and an export's archive. Nothing is buffered whole — the blob store hands back an open handle and the response walks it — so serving a fifty-megapixel frame costs no more memory than serving a thumbnail.
All four are named by something content-addressed, so all four carry
Cache-Control: public, max-age=31536000, immutable and an ETag holding the hash. That is not
optimism: identity is content in VisionSet, so the bytes behind one of these URLs cannot
change. A client that cached one never needs to revalidate it.
The two asset routes take an asset id, not a content hash, and the issue that asked for them
said hash. A hash names bytes and says nothing about what they are, so a route keyed on one
could only answer application/octet-stream — which a gallery cannot put in an <img>.
Resolving a hash back to its asset would fix that and needs a query the persistence port
deliberately does not have: its whole surface is one parent_id filter, so no query language
leaks into the port. Widening it for a download route would be the tail wagging the dog, so the
hash ships as the ETag instead, which is where it was doing the real work anyway.
Content-Type on the content route is whatever the ingest actually probed. An asset written
before the pipeline recorded a format is served as application/octet-stream, because inventing
one would be worse than admitting it. A thumbnail is always image/jpeg. An asset with no
cached preview is 404 THUMBNAIL_NOT_CACHED rather than an empty success — a preview is a cache
and reading one never renders one, so the remedy is a backfill and the code says so.
Request bodies forbid unknown fields. A misspelled key is a 422 VALIDATION_ERROR, never a
silently ignored one — a typo that looked like it worked is worse than a refusal.
Only what a service can honour is on the wire. PATCH /projects/{id} takes a name and nothing
else, because the SDK has no way to update a description. The API does not grow a field it would
have to fake.
Response shapes are wire models, not domain models. They live in server/models.py and are
written out field by field, so a field reaches a client because somebody published it and never
because somebody added it to an entity.
The compiled application is mounted at /app and / redirects to it. visionset server starts
both halves with one command; see cli.md.
The API owns the root, and that is why the app does not. /projects/{project_id} is a shipped
route, so an application served from / could never claim /projects/abc as one of its own
client routes — the API route matches first and answers 404 PROJECT_NOT_FOUND. That is not
something a later milestone can lift; it is the consequence of an unprefixed API. The prefix costs
one base line in frontend/app/vite.config.ts today and a public URL migration if left later.
Neither is in openapi.json, deliberately. A static mount is not an operation, and / is
declared include_in_schema=False. The spec is the REST contract; where a browser finds HTML is
not part of it, and keeping it out is also what keeps the drift gate and the generated client still
across a change to how the UI is served.
Nothing about the mount changes the API's answers. A root-level catch-all would have — it
matches every path, so it beats the partial match that produces a 405, and it shadows any route
registered after the application is built. POST /health is still 405 METHOD_NOT_ALLOWED, and an
unknown path under the prefix is still a 404 in the one error body below.
The deep-link fallback, since #58. A client route like /app/projects/abc is resolved by the
router in the browser, but a reload on it is a real request for a path no file backs — so without
a fallback, refreshing any page but the index is a 404, and so is every bookmark. The index is now
served for a 404 under three conditions, and each keeps something alive:
| condition | what it protects |
|---|---|
the path is under /app/ |
an unknown /projects/nope is still the API's own 404 — the same argument that put the bundle at /app |
the method is GET |
a POST to a client route is not a page load |
Accept contains text/html |
httpx and every other API client send */*, so the JSON 404 below is untouched |
That substring test is why no other test in tests/server needed changing when the fallback landed:
an API client never claims to be a browser. The response is 200, not 404 with a body — a page
that renders correctly must not claim to be missing.
It is installed by replacing the HTTPException handler and falling through to the normal one, not
as middleware: @app.middleware("http") wraps the application in BaseHTTPMiddleware, which buffers
a StreamingResponse, and four routes stream. And it is keyed on Starlette's HTTPException,
not FastAPI's subclass — the router raises the Starlette class for an unknown path and StaticFiles
raises it for a missing file, so keying on the subclass makes the fallback dead code for exactly the
two things that produce the 404 it exists for.
In a source checkout the bundle is absent until pnpm bundle:static runs, and / then answers a
404 naming that command. The API is unaffected.
Every failure — a domain refusal, a missing route, a malformed payload, an unhandled bug —
arrives as one shape, declared in the spec as ErrorBody:
{
"code": "BATCH_NOT_IN_ANNOTATION",
"message": "batch 3f2a… is approved, not in_annotation",
"detail": null
}| Field | |
|---|---|
code |
Stable and machine-readable. This is what a client branches on. |
message |
A sentence for a human. The wording is not part of the contract and may change. |
detail |
Extra structure, or absent. Its shape depends on the code — it is not uniform. |
Statuses are coarse by design, and two errors sharing one is normal. The case that makes this
concrete: DESTRUCTIVE_SCHEMA_CHANGE and SCHEMA_CHANGE_WOULD_ORPHAN are both 409. The
first is retryable — resubmit with allow_destructive=true and it succeeds. The second has no
override at all, because annotations already depend on what the change would remove. A client
that saw 409 and retried with the flag would loop forever against the second one. The kernel's
own error hierarchy is built to prevent exactly that loop; over HTTP, the code is the only
thing carrying that distinction.
Three rules, not one per error.
404 — the caller named something that is not there. A project, batch, job, asset, source,
dataset, annotation or release that was never created, was deleted, or belongs to a different
workspace. Cross-scope references read as missing, never as forbidden: an asset in another
project is a 404, not a 403. NO_SPLIT_RECIPE is here too — a release published without a
recipe has no split sub-resource, and never will, because a release is immutable.
One status covers more than one situation, which is the whole reason to branch on code:
GET /projects/{id}/schema answers 404 PROJECT_NOT_FOUND when the project is unknown and 404
SCHEMA_NOT_FOUND when the project is real and simply has no schema yet. Only the code separates
"you named nothing" from "there is nothing to name".
409 — the request is well-formed; the resource's state refuses it. The remedy is to change
that state and resubmit the identical request: finish the outstanding jobs, approve the batch,
promote something into the dataset, pass confirm=true. Name and tag collisions are here, as is
UNSERIALIZABLE_MANIFEST — the request body is fine and the defect is in state stored long
before, so the remedy is to fix the annotation and publish again.
422 — the payload itself is wrong. A blank name, a schema that declares two classes with one
name, an annotation that names a class the batch's pinned version does not have. Media failures
are here as well: UNSUPPORTED_MEDIA and CORRUPT_MEDIA describe a file that cannot become an
asset. They are deliberately not 415 — 415 is about the request's own Content-Type, and these
are raised while reading a file on disk that the operator pointed at.
503 — transient, and waiting helps. Exactly one error: WORKSPACE_BUSY. See below.
500 — nothing the caller can do. A corrupt or unreadable workspace, a store constraint no service pre-checked, a missing ffmpeg, or a bug.
Both are reachable on the same route, and they differ in detail:
VALIDATION_ERROR— the request failed pydantic validation before any service ran.detail.errorscarries pydantic's own list of per-field problems.- A domain refusal (
INVALID_NAME,INVALID_SCHEMA,LABEL_CLASS_NOT_IN_SCHEMA, …) — the payload parsed, and a kernel rule rejected it.detailis usuallynull.
Most malformed input arrives as the first: a LabelClass that cannot be constructed never
reaches a service to be refused by one.
A refusal from a bulk write carries detail.index — the position in the array you sent of
the item that caused it:
{ "code": "LABEL_CLASS_NOT_IN_SCHEMA", "message": "class 'ghost' is not in schema version 1, …",
"detail": { "index": 2 } }Bulk writes are all-or-nothing, so nothing was stored and the code alone cannot say which of
forty annotations meant it. The message is the reason; the index is the handle. It appears
only where it means something — a refusal about the call rather than about one item (a closed
batch, an unknown job) has no index and no detail.
A 5xx body carries an incident_id in detail, and its message is a fixed generic
sentence. The real message and traceback go to the server log under the same id — so an operator
greps one string, and a response body never becomes a channel for filesystem paths, SQL text, or
a stack trace.
Four errors opt out and expose their real message, each because that message is the remedy:
| Code | Why the message is published |
|---|---|
WORKSPACE_BUSY |
Names the contention; and the whole point is that a retry works. |
WORKSPACE_FORMAT_TOO_NEW |
Says which of the two things happened — a later VisionSet wrote it, or one that numbered its generations differently — and only one of those has a fix. |
WORKSPACE_SCHEMA_MISMATCH |
Names the table and column the workspace lacks. Opaque, this is a 500 with no cause on a route with no connection to it, and the answer is only in the server's log. |
MEDIA_TOOL_UNAVAILABLE |
Carries the install hint. Without it the error says nothing an operator did not suspect. |
A mapped 5xx keeps its own code (WORKSPACE_CORRUPT, CONSTRAINT_VIOLATED). An exception no
rule covers — a bug — gets INTERNAL_ERROR. That difference is how the two are told apart in a
log without reading the message.
MEDIA_TOOL_UNAVAILABLE is a 500 rather than a 503 on purpose: 503 promises that waiting helps,
and no amount of retrying installs ffmpeg.
| Code | Status | How to retry |
|---|---|---|
WORKSPACE_BUSY |
503 | Wait. The response carries Retry-After, currently 5 seconds — matched to the store's own busy timeout, because a client that gets this has already waited that long losing to another writer, and a shorter hint would aim a retry storm at the contention being reported. |
SCHEMA_VERSION_CONFLICT |
409 | Immediately. Two writers computed the same next version and this one lost; a retry re-reads the maximum and lands on the one after. No Retry-After, because there is nothing to wait for. |
STALE_WRITE |
409 | Immediately, after re-reading. Somebody moved the thing between your read and your write, so the retry is not the identical request — it is the request you would have sent had you seen the state the message names. There is deliberately no flag that writes anyway: that is the lost update this refusal exists to prevent. |
DESTRUCTIVE_SCHEMA_CHANGE |
409 | With allow_destructive=true, if narrowing the contract is what you meant. |
CONFIRMATION_REQUIRED |
409 | With confirm=true, after asking whoever is destroying the data. |
LOSSY_EXPORT_NOT_CONSENTED |
409 | With allow_lossy=true, if an incomplete copy is what you want. A third gate word beside the two above, because it guards a third thing: not destroying data and not narrowing a contract, but emitting a copy that leaves the original intact. |
Nothing else is retryable as-is. Note that four of these five are 409s and one of the 409s in
the table above — SCHEMA_CHANGE_WOULD_ORPHAN — is not retryable at all, which is the whole
argument for branching on code.
| Status | Codes |
|---|---|
| 404 | PROJECT_NOT_FOUND · SCHEMA_NOT_FOUND · BATCH_NOT_FOUND · JOB_NOT_FOUND · INGEST_JOB_NOT_FOUND · ASSET_NOT_FOUND · SOURCE_NOT_FOUND · DATASET_NOT_FOUND · ANNOTATION_NOT_FOUND · RELEASE_NOT_FOUND · ASSET_NOT_IN_JOB · NO_SPLIT_RECIPE · EXPORT_FORMAT_NOT_FOUND · THUMBNAIL_NOT_CACHED · NOT_FOUND (no such route) |
| 405 | METHOD_NOT_ALLOWED |
| 401 | UNAUTHORIZED — with a WWW-Authenticate: Bearer challenge |
| 409 | PROJECT_NAME_TAKEN · RELEASE_TAG_TAKEN · WORKSPACE_ALREADY_EXISTS · WORKSPACE_NOT_EMPTY · SCHEMA_VERSION_CONFLICT · INVALID_TRANSITION · STALE_WRITE · BATCH_NOT_EDITABLE · BATCH_NOT_IN_ANNOTATION · JOB_FINISHED · BATCH_NOT_COMPLETE · JOB_NOT_COMPLETE · EMPTY_BATCH · EMPTY_RELEASE · CONFIRMATION_REQUIRED · DESTRUCTIVE_SCHEMA_CHANGE · SCHEMA_CHANGE_WOULD_ORPHAN · UNSERIALIZABLE_MANIFEST · LOSSY_EXPORT_NOT_CONSENTED |
| 422 | VALIDATION_ERROR · INVALID_NAME · INVALID_SCHEMA · UNSUPPORTED_GEOMETRY · INVALID_ANNOTATION · LABEL_CLASS_NOT_IN_SCHEMA · DISALLOWED_GEOMETRY · MISSING_REQUIRED_ATTRIBUTE · UNKNOWN_ATTRIBUTE · INVALID_ATTRIBUTE_VALUE · INVALID_PARTITION · MEDIA_ERROR · UNSUPPORTED_MEDIA · CORRUPT_MEDIA |
| 503 | WORKSPACE_BUSY |
| 500 | WORKSPACE_CORRUPT · NOT_A_WORKSPACE · WORKSPACE_FORMAT_TOO_NEW · WORKSPACE_SCHEMA_MISMATCH · ENTITY_NOT_FOUND · ENTITY_ALREADY_EXISTS · CONSTRAINT_VIOLATED · MEDIA_TOOL_UNAVAILABLE · INTERNAL_ERROR |
CORRUPT_MEDIA and UNSUPPORTED_MEDIA carry detail.reason. The file's name is deliberately
absent from both the detail and the message: on the ingest path it is an absolute path inside a
directory the operator, not the client, pointed at.
Raise the kernel's domain error and stop. The handlers registered by create_app() do the rest,
and a route that catches a domain error to translate it itself is how a second error shape gets
into the contract.
server/errors.py holds one table, ERROR_RULES, with one entry per error class declared in
kernel/errors.py. tests/server/test_errors.py asserts that correspondence is exact, so a
new kernel error fails the suite until somebody maps it deliberately — which is the point.
Codes are written out as literals rather than derived from the class name. A code is a public
contract keyed to a Python identifier, and deriving it means a pure refactor rename silently
breaks every client while passing every test. A test asserts each literal still matches its class
name today; when a class genuinely is renamed, add it to that test's RENAMED map and leave the
code alone.
A couple of errors legitimately differ by route — an asset id in a path is a 404, and the same id in a request body is a 422. Catch it and return the renderer directly:
try:
service.record(...)
except AssetNotInJob as exc:
return error_response(exc, status=422)That is the whole escape hatch. Raising a bare HTTPException instead would produce a body in
the right shape but with a status-derived code nothing can branch on.
Do not commit a unit of work inside a Depends(...) teardown. FastAPI gives yield-dependencies
their own exit stack, and an exception raised there after the response has started produces
RuntimeError: Caught handled exception, but response already started rather than an ErrorBody
— which is precisely when WorkspaceBusy fires. Yield the service; the kernel already commits
inside its own unit_of_work().
422 is declared at app level, and that is load-bearing. It displaces FastAPI's generated
HTTPValidationError, keeping that model — and the second error shape it implies — out of
openapi.json entirely. A test asserts it never comes back.
401 is not declared at app level, and that is load-bearing too. /health is public and
cannot 401, so the guard and its documented response travel together on the router —
protected_router() in server/dependencies.py. Build every non-public router with it rather
than repeating Depends(require_token) per route; see auth.md.
Nobody writes a VisionSet HTTP call by hand. frontend/ui-core/src/generated/api.ts is generated
from the committed openapi.json by openapi-typescript, pinned to an
exact version in the root package.json, and @visionset/ui-core wraps it in a typed client:
import { createApiClient } from "@visionset/ui-core";
const api = createApiClient({ baseUrl: "http://127.0.0.1:8000", token });
const { data, error } = await api.GET("/projects/{project_id}", {
params: { path: { project_id: id } },
});The URL, its path parameters, the query, the request body and both response shapes are all typed off the contract. A misspelled route or a wrong parameter type fails to compile.
Regenerate with pnpm generate:client and commit the result — never hand-edit it. The output
is a tracked artifact for the same reason the spec is: a contract change then shows up in the pull
request as a reviewable type diff instead of appearing silently on somebody's next install.
| Gate | Where | Answers |
|---|---|---|
| Spec matches the app | the openapi CI job, and tests/server/test_openapi_contract.py |
did somebody change a route and forget to export? |
| Client matches the spec | the frontend CI job, and tests/scripts/generate_client.test.mjs |
did somebody change the contract and forget to regenerate? |
The frontend job installs no Python: it reads the committed spec, which the first gate has already established is current. Each half also runs inside the test suite of its own language, so the failure arrives where the mistake was made rather than on a pushed branch.
The pin is exact rather than a caret range because the output is committed — a routine minor bump would otherwise regenerate different bytes and fail the drift gate for a reason nobody chose.
One consequence worth knowing: openapi.json embeds info.version from the repo-root VERSION,
but the generated client contains only paths, components and operations. A version bump moves
the spec and leaves the client byte-identical. The two gates are genuinely independent.
openapi-typescript gives every response a static type off the contract and verifies
nothing at runtime. That gap is real: a well-formed JSON document of the wrong type
satisfies the compiler, reaches a screen intact, and takes the page down in the first
formatter that reads a field it does not have. It happened three times while the project
view was being rebuilt.
So pnpm generate:client emits a second committed artifact beside the types —
frontend/ui-core/src/generated/checks.ts, one check per schema a 2xx JSON response can
carry, plus one alias per operation named after its operationId — and unwrap takes one:
import { checkGetProjectStats } from "@visionset/ui-core";
const stats = unwrap(
await api.GET("/projects/{project_id}/stats", { params: { path: { project_id: id } } }),
checkGetProjectStats,
);A body that does not match is an ApiError under MALFORMED_RESPONSE, carrying the path
that disagreed (/classes/2/annotations should be an integer) — the same treatment an
unrecognisable error body has always had. The argument is in
frontend/ui-core/src/data/check.ts; three parts of it are worth stating here, because
each is a decision somebody will otherwise try to "fix":
- Unknown keys pass.
additionalProperties: falseconstrains what the API accepts, not what it may one day send. A client that refused an added field would turn every backward-compatible release into a broken page. formatis not enforced. Auuidis checked as a string and no further. A renderer is protected by the type; rejecting a legal ISO-8601 variant would be a new bug.- A property with a
defaultis treated as always present. A default means the server serializes it every time, which is whyopenapi-typescripttypes it as non-optional — and the check has to agree with the type or it would not compile against it.
The parameter is required, so a call site cannot forget it. It does not stop a call
site passing the wrong check: a type predicate is assignable whenever its asserted type
is, so unwrap(projectResult, checkDatasetOut) compiles and silently re-narrows. Pairing
each call with its own operation is enforced by tests/scripts/checks_wiring.test.mjs
instead — which is not a theoretical safeguard, since it caught two mispaired calls the
compiler accepted on the day it was written.
Four operations answer with bytes rather than JSON — asset content, thumbnails, the release
manifest and the export archive — and the spec declares them with an empty schema, OpenAPI's way
of saying "bytes, and there is nothing more to say about their shape". The generator is run with
emptyObjectsUnknown, so they come out as unknown.
That is deliberate, and it should not be "fixed" by declaring {"type": "string", "format": "binary"} on the Python side. That would make the generated type string, which is a lie in a
browser where the value is a Blob. Read those responses through response.blob().
Every media type a route can actually send is declared, though — get_asset_content lists
application/octet-stream beside the two image types, because an asset ingested before the
pipeline probed formats really is served that way, and a response the contract omits is a lie the
generated client inherits.