# The dawn HTTP binding

**Status:** current binding of the 2026-09-19 development surface. Candidate
verification and deployment evidence are in [STATUS.md](../STATUS.md). It
carries [SURFACE.md](SURFACE.md) without extending it: every route below is one
verb of that surface, and no route adds meaning the surface does not have.
Where the two disagree, the surface wins and this file is wrong. This is the
primary binding; [WS.md](WS.md) carries the same operations over a socket. Both
implementations use one table, [operations.json](operations.json), which the
drift tests hold to this document, to the surface and to the code;
[openapi.json](openapi.json) is generated from the same table for tooling that
reads OpenAPI, and is never the contract where it is silent.

## Shape

Requests and responses are JSON (`application/json`), except `retain`, whose
request body is the bytes themselves, and `fetch`, whose response body is the
bytes. Publishing and provisioning verbs are `POST` with a JSON object body;
reads are `GET` with query parameters; `query` and `explain` are `POST` with a
JSON body because their filters are structured. Records are returned exactly as
the surface defines them, so a client can decode them into the types of
`nightforge.lab/ulab-dawn/api`.

Native routes are relative to the operations listener root. Deployment mounts
that listener under `/api/operations/` and strips `/api/operations` before
forwarding; clients use the complete external base URL, such as
`https://dawn.example/api/operations`. The independent management listener is
mounted under `/api/management/`, as required by the shared
[management contract](../../ulab-standard/MANAGEMENT.md). These deployment
prefixes are not native service routes. Application routes live under `/v1/`; the
operational routes of [OPERATIONS.md](../../ulab-standard/OPERATIONS.md) live
at the root and are not versioned. The service's name is `dawn`, with no
version, and every response carries two headers:

```
Ulab-Service: dawn
Ulab-Contract: 2026-09-19
```

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

## Authentication carriage

Every route under `/v1/` is a surface call and carries the principal's proof
and grants in one header:

```
Dawn-Auth: <base64 of the JSON Carriage {"proof": {...}, "grants": [...]}>
```

The proof signs the **signing record** of the call, encoded per RFC 8785:
`{"op", "params", "body_digest", "principal", "nonce", "timestamp", "audience"}`,
where `op` is the table name of the operation, `params` are the table
parameters as a socket envelope carries them (strings, integers, arrays of
strings; a path as an array), `body_digest` is the `sha256:` digest of the
request body bytes, or of the empty string when there is none, and the other
four are the proof's own members. The signature is Ed25519 over those bytes by
the principal's key; the principal is `ed25519:<64 lowercase hex>` of the public
key. The audience is the operations endpoint in its http(s) form, including its
external path prefix and with no trailing slash, the same value over either
binding; the SDK defaults to it. A proof is one attempt: a retry of a publishing
verb carries a fresh proof and the same `operation_id`. What the service
verifies, in order, is the surface's: possession of the key, the signing record
rebuilt from what arrived, freshness and the replay cache, the audience, then a
grant chain from the root for the grant kind the row's `grant` column names on
its path (`kind` is the standard's operation kind: what the row does on the
wire). A failed proof
is `unverified` (`401`); a missing chain is `denied` (`403`); neither reveals
whether the path exists.

The operational routes carry no `Dawn-Auth`; a header on them is ignored.

## Routes

| Verb of the surface | Kind | Route |
|---|---|---|
| livez | operational | `GET /livez` |
| health | operational | `GET /healthz` |
| describe | operational | `GET /describe` |
| describe file | operational | `GET /describe/{file}` |
| resolve | read | `GET /v1/resolve?path=…&path=…` |
| children | read | `GET /v1/children?path=…&through&cursor&limit` |
| entries | read | `GET /v1/entries?path=…&after&through&limit` |
| get | read | `GET /v1/get?root_id&space_id&position` |
| fetch | read | `GET /v1/fetch?path=…&bytes` |
| snapshot | read | `GET /v1/snapshot?path=…&depth` |
| retain | publish | `PUT /v1/retain?path=…&type` with the bytes as the body |
| publish | publish | `POST /v1/publish` |
| create artifact | publish | `POST /v1/artifacts` |
| declare | declare | `POST /v1/declarations` |
| compose | publish | `POST /v1/compose` |
| apply | publish | `POST /v1/applications` |
| query | read | `POST /v1/query` |
| explain | read | `POST /v1/explain` |
| create project | provision | `POST /v1/projects` |
| provision worker | provision | `POST /v1/workers` |
| retire worker | provision | `POST /v1/workers/retire` |
| create campaign | provision | `POST /v1/campaigns` |
| assign | provision | `POST /v1/assignments` |
| export | provision | `GET /v1/export?path=…&cursor` |

The removed verbs have no route and no alias: `/v1/fork`, `/v1/list`,
`/v1/history` and `/v1/view` are `404 not_found` like any unknown route,
and `list`, `history` and `view` are served as `children`, `entries` and
`snapshot`.

A path is **repeated `path` parameters**, one label per occurrence, below the
world, never a joined string, so no separator or escaping exists on the wire.
`children`, `snapshot` and `export` without `path` select the world itself; an
empty `path=` is `invalid` like every empty parameter. A parameter has one name
in a query and in a socket envelope; the names, kinds and which are required are
in the table. `{file}` in the describe route spans slashes, so
`schema/PublishRequest.schema.json` is one file name.

## Request rules

- A JSON body is at most **64 MiB**; beyond that the response is `413` with an
  `invalid` envelope. A `retain` body is bounded by the bytes service's
  per-object limit, which the surface inherits.
- A `declare` request has an additional **16 MiB** limit for its original body;
  exceeding it is `invalid`. The same limit applies through WebSocket.
- Bodies are decoded strictly: unknown members, members named twice at any
  depth, and trailing data after the object are `invalid`.
- A `Content-Type` other than `application/json` on a JSON route, or other
  than `application/octet-stream` on `retain`, is `invalid`; an absent one is
  accepted.
- Query parameters are validated, not ignored: an unknown name, an empty value
  and a repeated singleton are each `invalid`. `path` is the only repeatable
  parameter. `limit` is an integer in 1..128; omitted or 0 is the default 64;
  129 is `invalid`. `through`, `after`, `position` and `depth` are integers in
  Journal's position domain: negative is `invalid`, `position` 0 is
  `invalid`, `after` beyond `through` is `invalid`. `through` must be 0 or a
  committed batch boundary of the selected journal: a position inside a
  batch is a valid `get` reference and an `invalid` cutoff, and a cutoff
  beyond the head is `invalid`. A continuation of `children` or `entries`
  resends the `through` its first page reported; a cursor with a
  disagreeing `through` is `invalid`. Parameters are validated before the
  carriage is verified, so a malformed request is `400` even without a
  proof; the domain rules above are the operation's and follow verification.
- Coordinates are authorized where they resolve. `root_id` and `space_id`
  outside their grammar are `invalid`; a `position` beyond the journal's
  head is `not_found`; a foreign root, an unknown space, the Journal root
  itself or any space outside the world is `not_found` for a caller who may
  read the world and `denied` for every other caller, so a refusal never
  discloses whether a foreign identity exists. The same rule applies to an
  `EntryRef` inside a body: a view, a selection, cited evidence.
- `through`, `after` and `cursor` are read selectors and are refused on
  publishing and provisioning verbs, except `export`, whose `cursor` resumes an
  incomplete traversal.

## Responses

| Situation | Status |
|---|---|
| Read, a replayed publishing or provisioning verb, `retain`, `snapshot`, `export` | `200` |
| Accepted publishing or provisioning verb | `201` |
| `unverified` | `401` |
| `denied` | `403` |
| `not_found` | `404` |
| `exists`, `conflict`, `idempotency_mismatch`, `unresolved` | `409` |
| `invalid`, `invalid_cursor`, `invalid_record`, `structural` | `400` |
| `limit_exceeded`, or a body over the limit | `413` |
| `unavailable` | `503` |
| Method not allowed on a known route (with `Allow`) | `405` |
| An internal failure (`internal`), or an operation the transitional source has not implemented (`not_implemented`, naming its item) | `500` |

A refusal body is `{"error": <code>, "message": <text>, "details": {…}}`. The
code is the machine-readable fact; clients must not parse the message. The
details carry what the surface promises: the expected and actual cutoffs for
`conflict`, the offending field for `invalid` and `invalid_record`, the
unresolved reference for `unresolved`, the colliding label for `structural`.
`denied` and `unverified` carry no details about the path.

A `201` versus a replay's `200` is a convenience, not the contract: `replayed`
in the body is the authority, because a proxy may rewrite a status.

Router-level failures use the same envelope: an unknown route is a `404`
`not_found`, never HTML or plain text, and a path that is not already clean
(`..`, `//`) is `404` too; the binding issues no canonical-path redirects.

## Streams

`fetch` answers `200 application/octet-stream` with `Content-Length` and the
bytes, verified against the requested `bytes` hash before the stream
completes: a mismatch closes the connection before the last byte, so a client
that received a complete body received the right one. `retain` reads the
request body to its end, hashes it, retains it in bytes and answers with the
`Artifact`; an aborted body retains nothing. Cancellation of either is the
client closing the connection; the service keeps nothing.

## Operational routes

Per [OPERATIONS.md](../../ulab-standard/OPERATIONS.md): `GET /livez` answers
the health envelope with empty `checks` and is never `503`; `GET /healthz`
answers it with two checks, `journal` and `bytes`, each the reachability and
service identity of the backend Dawn consumes, `200` when both are `ok` and
`503` otherwise; `GET /describe` answers the service's description with
`identity` carrying the Journal root, the world's space and the Journal
instance; `GET /describe/{file}` serves one committed contract file, verbatim,
from the files embedded at build. `build.revision` and `build.dirty` come from
the binary's build information, else from the link-time variables
`nightforge.lab/ulab-dawn/components/service/internal/buildinfo.Revision` and
`.Dirty`, else `"unknown"` and `true`.

## Representative calls

These wire examples are schematic: the abbreviated hashes and
`<signed-carriage>` must be replaced by actual retained values and a proof over
the exact request bytes. They are not transcripts or deployment evidence.
The [worker guide](../docs/WORKERS.md) supplies an executable CLI workflow.

**Create an artifact.** The parent project exists and the bytes are retained.

```http
POST /v1/artifacts HTTP/1.1
Content-Type: application/json
Dawn-Auth: <signed-carriage>

{"operation_id":"artifact-1","path":["projects","demo","artifacts","source"],
 "artifact":{"bytes":"sha256:…","type":"sha256:…"}}
```

A successful `201` returns an `ArtifactResult` naming the artifact space, its
content occurrence at position 1 and its declarations space. Retrying the same
request recovers an interrupted creation.

**Append a declaration.** The caller has `declare` on that artifact and `read`
on its content and every cited occurrence.

```http
POST /v1/declarations HTTP/1.1
Content-Type: application/json
Dawn-Auth: <signed-carriage>

{"operation_id":"inspection-1",
 "artifact":["projects","demo","artifacts","source"],
 "statement":{"predicate":"inspection","finding":"The selected input was inspected."}}
```

A successful `201` returns a `DeclareResult` with the declaration occurrence in
the artifact's `declarations` journal, its assertion ID and the envelope's
digest. Fetching the envelope by that digest at the declarations path yields the
`DeclarationRecord`: its request, statement, exact content, authenticated
publisher and signed admission evidence, with the exact request-body bytes
preserved separately from the decoded request.

**Publish a record.** The caller has `publish` at the leaf's path.

```http
POST /v1/publish HTTP/1.1
Content-Type: application/json
Dawn-Auth: <signed-carriage>

{"operation_id":"note-1","path":["projects","demo","workers","w1","notes","first"],
 "profile":"dawn/inventory/1","record":{"records":{"fn#main":{"name":"main"}}}}
```

A successful `201` returns a `PublishResult`: the `Record` occurrence with
its `entry`, `profile`, the `hash` of the record's canonical form, the
`metadata`, the authenticated `publisher`, the `request_id` of this
principal's operation scoped to the leaf, the `request_digest` of the
normalized request and the `accepted_at` instant; `space_created` reports
that this operation created the leaf. The same request again is `200` with
`replayed: true` and the same occurrence, digest and instant; the same
`operation_id` with other content is `409 idempotency_mismatch`; a stale
`expected_cutoff` is `409 conflict` with the expected and actual cutoffs.

**Recover an uncertain declaration.** Resend the same semantic request and
`operation_id` with a fresh carriage. A replay returns `200` and
`replayed: true`, preserving the first occurrence and admission proof. A changed
statement under the same operation ID is `409 idempotency_mismatch`. A new
correction uses another operation ID and an explicit `relation` occurrence.

**Read the declaration's entry.** Occurrences are read by reference:

```http
GET /v1/get?root_id=rt_…&space_id=spc_…&position=1 HTTP/1.1
Dawn-Auth: <signed-carriage>
```

**Refusals.** An invalid proof is `401 unverified`; missing declaration or
read authority is `403 denied` without disclosing existence. Generic
`POST /v1/publish` cannot write an artifact space's content or `declarations`
journal, even with a broad publish grant. A supersession target from another
publisher is refused; no earlier declaration is changed.
