# Journal over HTTP

**Status:** primary binding of the [2026-09-12 surface](SURFACE.md).

HTTP request and response is Journal's binding in this lane; a WebSocket peer
binding of the same operation table is deferred (proposal 0016 §8). The
service name is `journal`; application routes use `/v1`. Standalone
operation defaults to `http://127.0.0.1:8092`. Clients select an explicit
endpoint using `--server`, then `ULAB_JOURNAL_SERVER`, then that default.
TLS and network access are deployment concerns; this listener authenticates
no request, including self-description requests.

Routes below are native listener paths. ULab deployment mounts operations at
`/api/operations`, stripping that prefix before forwarding. Management uses
the separate `/api/management` prefix, following the
[family contract](../../ulab-standard/MANAGEMENT.md).

This document describes the operations listener. The independently bound
[management listener](management/HTTP.md) defaults to `127.0.0.1:8093` and
has its own contract files and `Ulab-Surface: management` discriminator.

## Common rules

Every response, including router failures, carries:

```text
Ulab-Service: journal
Ulab-Surface: operations
Ulab-Contract: 2026-09-12
```

Every semantic operation is a `POST` to `/v1/<operation>` whose JSON body is
the request object; there are no query parameters on application routes.
A request may omit `Content-Type`. When present, its parsed media type must
be `application/json`, with no parameter other than an optional UTF-8
charset. JSON requests contain one object with exactly the documented
members, followed only by whitespace. Required members are present and
non-null; optional members are omitted or, where the surface allows null,
null. Unknown members, case variants of member names, duplicate member names,
malformed UTF-8, unpaired Unicode surrogates and trailing data are refused.

The request envelope is limited to 8 MiB (8,388,608 bytes), independently of
the decoded value bounds. Oversized envelopes return status 413 and
`invalid`; a value exceeding its declared bound returns 413 and
`limit_exceeded`. Facts, filter predicates and filter values travel as
canonical padded standard base64 (RFC 4648 §4) of their canonical Ontos
bytes, without whitespace; decoding and re-encoding must reproduce the string
exactly, so missing or unnecessary padding, nonzero unused pad bits and the
URL-safe alphabet are refused. Paths are JSON arrays of keys, never joined
strings. Numeric controls are canonical nonnegative integers at most
`9007199254740991`; fractions, exponents and signs are refused. Cursors are
opaque strings the service issued.

Responses use `application/json`, except a served description file's
documented media type. Clients may tolerate unknown optional response members
but must validate required members, identities, positions and mutually
consistent bounds. They must reject a missing or different `Ulab-Service` as
a protocol error, separate from a service refusal. A different contract date
is exposed to the caller rather than automatically rejected.

## Application routes

| Operation | Method and route | Request | Success |
|---|---|---|---|
| `create` | `POST /v1/create` | `CreateRequest` | 201 new, 200 replay; `CreateResult` |
| `resolve` | `POST /v1/resolve` | `ResolveRequest` | 200; `ResolveResult`, whose `space` may be null |
| `children` | `POST /v1/children` | `ChildrenRequest` | 200; `ChildrenResult` |
| `head` | `POST /v1/head` | `HeadRequest` | 200; `HeadResult` |
| `append` | `POST /v1/append` | `AppendRequest` | 201 new, 200 replay; `AppendResult` |
| `read` | `POST /v1/read` | `ReadRequest` | 200; `ReadResult` |
| `query` | `POST /v1/query` | `QueryRequest` | 200; `QueryResult` |

The operation table and schemas are published as [operations.json](operations.json),
[openapi.json](openapi.json) and `schema/<Type>.schema.json`. The surface's
semantics govern where generated shape descriptions are silent.

### POST /v1/create

```http
POST /v1/create HTTP/1.1
Content-Type: application/json

{"parent":{"root_id":"rt_11111111111111111111111111111111","space_id":"spc_22222222222222222222222222222222"},"key":"projects","operation_id":"create-projects-1"}
```

A new child returns status 201:

```json
{
  "child": {"root_id": "rt_11111111111111111111111111111111", "space_id": "spc_33333333333333333333333333333333", "path": ["projects"]},
  "parent_entry": {"root_id": "rt_11111111111111111111111111111111", "space_id": "spc_22222222222222222222222222222222", "position": 1},
  "parent_cutoff": 1,
  "replayed": false
}
```

Retrying the same request returns status 200 and the same result with
`replayed: true`. `expected_cutoff` is the only optional member; omitted or
null means unconditional. A taken key is 409 `exists`; a stale precondition
is 409 `conflict`; an absent parent is 404 `not_found`.

### POST /v1/resolve

Either `{"space": {"root_id": …, "space_id": …}}` or
`{"root_id": …, "path": ["projects", "claude-cli"]}`; the empty array is the
root. Status 200 carries `{"space": Space}` or `{"space": null}` for an
unoccupied path. An unknown exact identity or root is 404 `not_found`.

### POST /v1/children

```http
POST /v1/children HTTP/1.1
Content-Type: application/json

{"space":{"root_id":"rt_1111…","space_id":"spc_2222…"},"limit":2}
```

Status 200 returns one page of the child set at the selected cutoff:

```json
{
  "space": {"root_id": "rt_1111…", "space_id": "spc_2222…"},
  "through": 3,
  "children": [
    {"key": "projects", "space": {"root_id": "rt_1111…", "space_id": "spc_3333…", "path": ["projects"]}, "created_at": {"root_id": "rt_1111…", "space_id": "spc_2222…", "position": 1}},
    {"key": "ledger", "space": {"root_id": "rt_1111…", "space_id": "spc_4444…", "path": ["ledger"]}, "created_at": {"root_id": "rt_1111…", "space_id": "spc_2222…", "position": 2}}
  ],
  "next_cursor": "eyJr…"
}
```

Continue with `{"space": …, "cursor": "eyJr…", "limit": 2}`; a final page
omits `next_cursor`. `through` and `cursor` may be combined only when they
agree. An interior or future `through` is 400 `invalid`.

### POST /v1/head

`{"space": SpaceRef}` returns `{"space": SpaceRef, "cutoff": N}`.

### POST /v1/append

```http
POST /v1/append HTTP/1.1
Content-Type: application/json

{"space":{"root_id":"rt_1111…","space_id":"spc_3333…"},"operation_id":"append-1","expected_cutoff":0,"facts":["AQEAAXA=","AQIAAXAAAf8="]}
```

If the journal is empty, the result is status 201:

```json
{"space": {"root_id": "rt_1111…", "space_id": "spc_3333…"}, "first": 1, "last": 2, "cutoff": 2, "replayed": false}
```

An exact semantic retry returns status 200, the original bounds and
`replayed: true`. `facts` is a non-null array of 1..128 base64 strings, each
one canonical Ontos fact of at most 1 MiB, at most 4 MiB in total. A failed
precondition returns:

```http
HTTP/1.1 409 Conflict
Content-Type: application/json

{"error":"conflict","message":"the committed cutoff differs from the expected cutoff","details":{"expected_cutoff":0,"actual_cutoff":2}}
```

### POST /v1/read

```http
POST /v1/read HTTP/1.1
Content-Type: application/json

{"space":{"root_id":"rt_1111…","space_id":"spc_3333…"},"through":2,"limit":1,"filter":{"predicate":"AAFw"}}
```

Status 200 returns one page:

```json
{
  "space": {"root_id": "rt_1111…", "space_id": "spc_3333…"},
  "through": 2,
  "entries": [{"ref": {"root_id": "rt_1111…", "space_id": "spc_3333…", "position": 1}, "fact": "AQEAAXA=", "origin": "append", "batch": {"first": 1, "last": 2}}],
  "scanned_through": 1,
  "next_after": 1
}
```

Continue with `after: 1, through: 2` and the same filter. A continuation
without `through` is 400 `invalid`. A `filter` object carries any of
`predicate`, `arguments` (`[{"index", "equals"}]`), `origin` and
`positions` (`{"after", "through"}`).

### POST /v1/query

```http
POST /v1/query HTTP/1.1
Content-Type: application/json

{"space":{"root_id":"rt_1111…","space_id":"spc_2222…"},"max_depth":1,"limit":64}
```

Status 200 returns the captured snapshot and the first page:

```json
{
  "snapshot": {
    "scope": {"root_id": "rt_1111…", "space_id": "spc_2222…"},
    "max_depth": 1,
    "spaces": [
      {"space": {"root_id": "rt_1111…", "space_id": "spc_2222…", "path": []}, "through": 3},
      {"space": {"root_id": "rt_1111…", "space_id": "spc_3333…", "path": ["projects"]}, "through": 2},
      {"space": {"root_id": "rt_1111…", "space_id": "spc_4444…", "path": ["ledger"]}, "through": 0}
    ]
  },
  "entries": ["…"],
  "next_cursor": "eyJr…"
}
```

Continue with the same `space`, the returned `snapshot`, the unchanged
`filter` and the `cursor`. A capture that would exceed the snapshot bounds or
the 8 MiB request budget of a complete continuation is 413 `limit_exceeded`.
An inconsistent submitted manifest is 400 `invalid`.

## Refusal and router mapping

Errors use this envelope; `details` is present when documented data is available:

```json
{"error":"invalid","message":"space.space_id must be spc_ followed by 32 lowercase hexadecimal digits","details":{"field":"space.space_id"}}
```

| Code | HTTP status | Meaning |
|---|---|---|
| `invalid` | 400 | Invalid member, value, encoding, cutoff, continuation, manifest or request shape |
| `not_found` | 404 | Unknown root, exact space or route |
| `exists` | 409 | The child key is occupied |
| `conflict` | 409 | The expected cutoff differs from the committed cutoff |
| `idempotency_mismatch` | 409 | The scoped operation ID already has a different successful request |
| `limit_exceeded` | 413 | A declared resource bound would be exceeded |
| `unavailable` | 503 | The service cannot presently complete the operation |
| `internal` | 500 | Unexpected service failure |

`invalid` also maps to 413 for an oversized request envelope and to 405 for
an unsupported method on a known route; a 405 includes `Allow` naming the
supported method. Unknown paths return 404 `not_found`; reserved but
unimplemented `/metrics` and `/statusz` also return 404. Clients must not
parse message text to distinguish errors. A transport failure, truncated
response or invalid service header is a client-side protocol failure, not a
refusal.

Losing a mutation response leaves its outcome uncertain. Retry the original
request and operation ID against the original service. A successful response
means the complete mutation and its receipt were committed; an interrupted
connection is not proof that they were not.

## Operational routes

These implement the [ULab operational profile](../../ulab-standard/OPERATIONS.md)
and are not semantic operations. They accept no body or query parameters.

| Method and route | Successful response | Refusals |
|---|---|---|
| `GET /livez` | 200, health envelope with empty `checks` | Invalid request shape; never readiness status 503 |
| `GET /healthz` | 200 when all checks are `ok`, health envelope | 503 with `status: "failing"` when a dependency check fails |
| `GET /describe` | 200, embedded description JSON | Invalid request shape |
| `GET /describe/{file}` | 200, listed contract file verbatim | 404 `not_found` for an unlisted file; invalid request shape |

The health envelope contains `status`, `service`, `contract`, `build`,
`time`, `checks` and `identity`. `identity` carries `root_id`,
`root_space_id` and `journal_instance_id`; `build` carries the full source
`revision`, `dirty` and `go`; `time` is informational UTC time. Health has an
`engine` check and an `identity` check.

The build reads version-control information from the executable when
available. Builders that disable or cannot supply that information stamp
these string variables at link time:

```text
nightforge.lab/ulab-journal/internal/buildinfo.Revision
nightforge.lab/ulab-journal/internal/buildinfo.Dirty
```

`/describe` reports the service, contract, build, identity, bindings, the
operation table, the refusal table, the declared limits, the pinned profiles
and the served file list. Its primary binding is `http`, with prefix `/v1`.
`/describe/{file}` serves exactly the files it lists, embedded at build time:
`SURFACE.md`, `HTTP.md`, `operations.json`, `openapi.json` and the generated
`schema/*.schema.json` files. Markdown is `text/markdown`; JSON is
`application/json`. Paths outside that list are never read from the host
filesystem.
