Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ contracts (kernel purity, headless annotator) are described there and enforced i
| [events.md](events.md) | Domain events: subscribing by type, why emission follows the commit, at-most-once delivery, and what an isolated subscriber failure does |
| [persistence.md](persistence.md) | The metadata store: repositories, unit of work, table layout, migrations and `format_version` |
| [examples.md](examples.md) | The two runnable examples: the whole cycle in one pass, ingest on its own, and what each is built to demonstrate |
| [api.md](api.md) | The REST surface: the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying |
| [api.md](api.md) | The REST surface: the conventions every endpoint follows (paths, UUID ids, the list envelope, gates as query parameters), the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying |
| [auth.md](auth.md) | Who may call it: per-workspace API tokens, why only a digest is stored, why every refusal is one identical 401, immediate revocation, the `visionset token` commands, and how a protected route is built |
64 changes: 64 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,65 @@ workspace named by `VISIONSET_WORKSPACE`, and one pointed at something else answ
`NOT_A_WORKSPACE`. See [auth.md](auth.md) for the whole picture, including how to build a
protected route.

## Conventions

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}/schema the version in force
POST /projects/{project_id}/schema/versions
GET /projects/{project_id}/schema/versions
GET /projects/{project_id}/schema/versions/{version}
```

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.

**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:

```json
{ "items": [ { "id": "…", "name": "road-signs", "description": null } ], "total": 1 }
```

An array cannot grow a field without breaking every client that parsed it. There are no paging
parameters yet — the kernel has no windowed read, and a `limit` implemented by slicing a full
read would be a window that lies about its cost. `total` already means *matching the query*
rather than *in this page*, so `limit` and `offset` can be added beside it later without a
breaking change. An empty collection is `{"items": [], "total": 0}` and a 200, never a 404.

**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`.

**Statuses.** 201 with the created resource in the body; 200 for a read or an update; 204 with an
empty body for a delete.

**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 error body

Every failure — a domain refusal, a missing route, a malformed payload, an unhandled bug —
Expand Down Expand Up @@ -61,6 +120,11 @@ workspace. Cross-scope references read as *missing*, never as *forbidden*: an as
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
Expand Down
5 changes: 5 additions & 0 deletions docs/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ with WorkspaceService.open("./road-signs") as workspace:
projects.delete(project.id, confirm=True)
```

**Over HTTP:** `POST`/`GET /projects`, `GET`/`PATCH`/`DELETE /projects/{project_id}`, where
`PATCH` renames and `DELETE` needs `?confirm=true`. The semantics below are the same ones — the
REST surface is a thin client of this service. See [api.md](api.md) for the conventions and
`openapi.json` for the exact shapes.

## The project–dataset relation is 1:1

The dataset **is** the curated state of the project, not a thing kept beside it. Three
Expand Down
6 changes: 6 additions & 0 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ with WorkspaceService.open("./road-signs") as workspace:
schemas.allowed_geometries(project.id) # {BBOX, POLYGON}
```

**Over HTTP:** `POST`/`GET /projects/{project_id}/schema/versions`,
`GET /projects/{project_id}/schema/versions/{version}`, and `GET /projects/{project_id}/schema`
for the version in force. Narrowing needs `?allow_destructive=true`, exactly as
`allow_destructive=` does here. `preview`, `compare` and `allowed_geometries` have no route yet —
they will get one when a surface needs them. See [api.md](api.md).

## Versions are 1..N, and none of them changes

The next version is one past the highest stored, so the numbers have no gaps and no reuse.
Expand Down
Loading
Loading