# The HTTP binding

One binding of [the surface](SURFACE.md). It carries the nine verbs on five
routes; it adds nothing.

Application routes live under `/v1`. The version belongs to this binding, not
to the service: the contract's worth is that it does not change, and the
prefix is what lets that claim survive being wrong. The service's name is
`bytes`, with no version, and every response carries two headers:

```
Ulab-Service: bytes
Ulab-Contract: 2026-09-10
```

`Ulab-Contract` is the date on the surface's contract line. A client treats a
response without `Ulab-Service: bytes` as a protocol error, before reading
its body, whatever its status.

## Routes

| route | verbs |
|---|---|
| `GET /v1/blobs/{name}` | `get`, `get range`, `get stream` |
| `POST /v1/stat` | `has`, `size` |
| `POST /v1/blobs` | `put`, `put stream` |
| `POST /v1/get` | `get many` |
| `POST /v1/put` | `put many` |

### `GET /v1/blobs/{name}` — get, get range, get stream

Returns the bytes, `200`, `Content-Type: application/octet-stream`, with
`Content-Length` when known. Whether the client treats the body as a whole
blob or a stream is the client's business; the wire is identical.

Query parameters `offset` and `length` (non-negative integers) select a
range. Per the surface, the response is the overlap: `length` is clamped to
the end, an `offset` at or past the end yields an empty `200`. The HTTP
`Range` header is not part of this binding — its unsatisfiable-range semantics
(`416`) would smuggle in a third refusal.

Cancellation is the client closing the connection. The server sends nothing
further and keeps nothing.

Errors: `404` if the name is not here.

### `POST /v1/stat` — has, size

Request `application/json`:

```json
{"names": ["sha256:…", "sha256:…"]}
```

Response `200 application/json`:

```json
{"sizes": {"sha256:…": 1024}}
```

`sizes` holds an entry for every requested name that is here, and no entry
for one that is not. This one operation is both plural verbs: presence of the
key is `has`, its value is `size`.

### `POST /v1/blobs` — put, put stream

The request body is the bytes, raw. The server hashes what arrives and
commits at clean end-of-body — for a streamed (chunked) request this is the
atomic commit the surface pins: an incomplete body, whoever aborted it,
commits nothing. Content already present short-circuits at commit time; the
response does not distinguish it, because the verb is idempotent and the
distinction is not the client's business.

Response `200 application/json`:

```json
{"name": "sha256:…", "size": 1024}
```

Errors: `413` if the bytes exceed the deployment cap — including when the cap
trips mid-stream, in which case nothing was committed.

### `POST /v1/get` — get many

Request `application/json`:

```json
{"names": ["sha256:…", "sha256:…"]}
```

Response `200 application/x-bn-blobs`: a sequence of records, one per blob
that is here, in any order. Each record is an ASCII header line

```
<name> <size>\n
```

followed by exactly `<size>` raw bytes. The sequence ends at end-of-body.
Requested names that are not here produce no record. Duplicate requested
names produce one record.

### `POST /v1/put` — put many

Request `application/x-bn-blobs` with anonymous records — the header line is
just `<size>\n`, since the name is what the server is being asked to compute
— each followed by exactly `<size>` raw bytes.

Response `200 application/json`, one entry per record, **in request order**
(the items have no names until now, so order is the only key there is):

```json
{"results": [
  {"name": "sha256:…", "size": 3},
  {"error": "too_large", "message": "the body exceeds the per-blob limit"}
]}
```

Each item commits independently, per the surface: an oversized item refuses
alone, its neighbors land, and a batch cut off mid-body commits the records
that arrived whole and nothing of the one that did not.

## Operational routes

Four routes at the root, per ulab-standard/OPERATIONS.md, without credentials
and outside the surface. They are rows of kind `operational` in
[operations.json](operations.json).

| route | answers | status |
|---|---|---|
| `GET /livez` | the process is up | `200` always |
| `GET /healthz` | the store can serve: the data directory exists and a temporary file can be created where commits stage | `200`, or `503` with the failing check |
| `GET /describe` | the service's description: name, contract date, build, bindings, the operation table, the refusal table, the served files | `200` |
| `GET /describe/{file}` | one committed contract file, verbatim, by a name `describe.files` lists; `{file}` spans slashes for `schema/<Type>.schema.json` | `200`, `404 not_found` otherwise |

`/livez` and `/healthz` answer the health envelope:

```json
{"status":"ok","service":"bytes","contract":"2026-09-10",
 "build":{"revision":"<vcs revision>","dirty":false,"go":"go1.26.3"},
 "time":"2026-09-10T02:10:00Z",
 "checks":[{"name":"store","status":"ok","detail":"data directory writable"}]}
```

`/livez` carries an empty `checks` array. `build` comes from the binary's
build information, or from the link-time variables
`github.com/Bitspark/bn-bytes/impl/cloud/go/api.Revision` and `.Dirty` when
the build carried none (`-buildvcs=false`), or is `"unknown"` and `true`.
A bytes store has no instance identity, so the envelope and the description
carry no `identity` member.

`/describe` serves the files embedded in the binary at build: `SURFACE.md`,
`HTTP.md`, `operations.json`, `openapi.json` and `schema/<Type>.schema.json`,
each verbatim as `text/markdown` or `application/json`. A drift test holds
the embedded files byte-equal to the committed ones.

## Validation

Since 2026-09-10 the binding refuses what it does not document, per the
family's validation rule: on `GET /v1/blobs/{name}` any query parameter other
than `offset` and `length`, a repeated one, or an empty one is `400 bad_request`;
a JSON body for `/v1/stat` or `/v1/get` must be one object whose only member is
`names`, given once, followed by nothing but whitespace, and any unknown or
repeated member, trailing data or non-array `names` is `400 bad_request`. JSON
bodies are limited to 4 MiB. A client that misspells a parameter hears about
it instead of receiving the whole blob. The Go router's own `404` and `405`
answers and its canonical-path redirects are outcomes of this binding, not
refusals of content; a validated SDK refuses redirects and is configured with
the final endpoint. Batch responses are buffered in proportion to the item
count; there is no item-count cap.

## Errors

Error responses carry `application/json`:

```json
{"error": "not_found", "message": "no such object or route"}
```

| status | error | where |
|---|---|---|
| `404` | `not_found` | `GET /v1/blobs/{name}` |
| `413` | `too_large` | `POST /v1/blobs`, per-item in `POST /v1/put` |
| `400` | `bad_request` | a malformed name or request body — a protocol error of this binding, not a refusal of content |

Nothing else is a refusal. `5xx` means the server failed, not that it
refused; a client retries or fails over, which content addressing makes safe.
