# The Journal surface

**Status:** semantic contract, 2026-09-12 — the space-scoped surface of
[proposal 0016](../../docs/proposals/0016-journal-api-synthesis.md), adopted by
[order 0002](../../docs/orders/0002-replace-registry-with-journal.md). This
lane (J0) implements it on an in-memory engine; the durable provider is J1.

Journal retains one tree of **spaces** under a root. Every space, the root
and every intermediate node included, owns one append-only **journal** of
**facts**. Creating a child space is recorded in the parent's journal, so the
tree replays through ordinary reads. This is the complete semantic contract.
The [HTTP binding](HTTP.md) carries it without adding operations or changing
their meaning. Implementation and verification status belong in the
service's [README](../README.md).

## Boundary

Journal does not interpret a fact. It prescribes no predicate grammar, types,
references, signatures, attribution, retractions or current-value rules. A
consumer's withdrawal is another fact; nothing removes an earlier entry.
Journal enforces no authority: it is an identity-blind backend behind a
deployment boundary and an explicitly selected higher layer.

There is no deletion, renaming, moving or replacement of spaces (the
lifecycle is additive), no transaction across journals or across Bytes and
Journal, no total order across spaces, and no global snapshot. Retention,
collection, subscriptions, joins, traversal and full-text search are above
this contract.

## Values and limits

| Value | Definition |
|---|---|
| Root ID | `rt_` followed by 32 lowercase hexadecimal digits; the logical hierarchy, independent of any endpoint |
| Space ID | `spc_` followed by 32 lowercase hexadecimal digits; one continuing space and its journal, allocated at creation |
| Instance ID | `jinst_` followed by 32 lowercase hexadecimal digits; the store lineage, reported operationally |
| Key | 1..255 bytes of UTF-8 with no Unicode control character (C0, DEL and C1) and no `/`; compared byte for byte, never normalized. `.` and `..` are ordinary keys |
| Path | An array of keys under the root; the empty array is the root |
| Operation ID | 1..128 ASCII characters from `[A-Za-z0-9._:-]` |
| Position | Integer from 1 through 9,007,199,254,740,991 (`2^53-1`), meaningful within one journal |
| Cutoff | 0, or the final position of a committed batch |
| Fact | One canonical Ontos value under the profile below, at most 1 MiB of canonical bytes |
| Append batch | 1..128 facts; at most 4 MiB of canonical fact bytes in total. An empty batch is a malformed shape (`invalid`); exceeding either bound is `limit_exceeded` |
| Page | Default 64, at most 128 entries or children; at most 4 MiB of canonical fact bytes; at most 4,096 entry candidates and 16 MiB of fact bytes examined |
| Snapshot | At most 4,096 spaces and 4 MiB of canonical JSON; the same inspection budget for its topology, across the whole scope |
| Cursor | At most 4 KiB; opaque; restart-safe |
| Request envelope | At most 8 MiB of JSON, including a complete query continuation |

MiB means 1,048,576 bytes. Byte limits count decoded values, never transport
encoding. These limits are an operational admission profile, advertised by
`/describe`; they do not narrow Ontos's semantics or equality. Exceeding a
declared bound is `limit_exceeded` and does not certify the remainder.

```text
SpaceRef  = {root_id, space_id}
Space     = {root_id, space_id, path}
EntryRef  = {root_id, space_id, position}
Entry     = {ref, fact, origin, batch: {first, last}}
```

`/` is the one character a key may not carry, and the reason is rendering
rather than storage: a path is an array on the wire, but diagnostics, logs and
command lines render it as text, and `["a/b"]` and `["a", "b"]` would render
alike. A consumer needing `/` inside one name encodes it; every reader of a
path keeps an unambiguous one. `.` and `..` carry no meaning here — this
interface has no filesystem and never resolves a path relative to another.

IDs compare byte for byte; clients treat them as opaque and never derive them
from content. A space's identity never changes; its path is its location
under the current topology and, under the additive lifecycle, never changes
either. Equal facts at different positions are distinct occurrences. Every
operation that selects a space takes an exact `SpaceRef`; only `resolve`
accepts a path.

### Facts

The profile is `ulab-journal-facts-v1`: the existing Ontos L0 value model,
the frozen L1 compound reading and the frozen `ontos-codec-v1` encoding. A
fact is a non-empty tuple whose first child is any atom, the predicate; the
remaining children are arbitrary values. Labels may be empty or non-UTF-8;
Journal adds no grammar for them. Equality is Ontos structural identity,
equivalently equality of the canonical codec bytes. Decoding a submitted value
and re-encoding it must reproduce the submitted bytes; any other encoding is
`invalid`. Values are decoded under depth ≤ 256 (the root at 0, each
tuple-child edge adding 1), tuple arity ≤ 65,536 and atom payload ≤ 1 MiB.

The service produces one value itself. When a child is created, the parent's
journal receives the creation fact

```text
creation(key, id) = Tuple(Atom("ulab.journal.child_created.v1"), text(key), text(id))
```

with `text` the Ontos `utf8-text` embedding. A caller may append a value equal
to it; that entry has origin `append` and creates nothing.

### Origins and batches

Every entry carries its origin: `create` for the creation entry the service
appended, `append` for everything a caller appended. Every entry carries its
batch, the contiguous range committed together with it; a batch's `last` is
a cutoff. The current cutoff of a space is the end of its latest committed
batch, or 0 when empty. It is also the observation of the space's immediate
children: the child set at cutoff `c` is exactly the set of creation entries
at positions ≤ `c`, in position order.

## Operations

| Operation | Request | Result |
|---|---|---|
| `create` | `parent`, `key`, `operation_id`, optional `expected_cutoff` | `child`, `parent_entry`, `parent_cutoff`, `replayed` |
| `resolve` | `space`, or `root_id` with `path` | `space` or null |
| `children` | `space`, optional `through`, `cursor`, `limit` | `space`, `through`, `children`, optional `next_cursor` |
| `head` | `space` | `space`, `cutoff` |
| `append` | `space`, `facts`, `operation_id`, optional `expected_cutoff` | `space`, `first`, `last`, `cutoff`, `replayed` |
| `read` | `space`, optional `after`, `through`, `limit`, `filter` | `space`, `through`, `entries`, `scanned_through`, optional `next_after` |
| `query` | `space`, optional `filter`, `max_depth`, `snapshot`, `cursor`, `limit` | `snapshot`, `entries`, optional `next_cursor` |

### create

`create(parent, key, operation_id, expected_cutoff?)` creates a child space
and its empty journal under an existing parent in one atomic mutation: it
allocates the child's identity, establishes its key uniquely among the
parent's children, appends the creation fact to the parent's journal with
origin `create`, and stores the receipt. The result names the child, the
creation entry in the parent and the parent's cutoff after it, which equals
the entry's position.

An absent parent is `not_found`; a key already occupied under that parent is
`exists`; a supplied `expected_cutoff` that differs from the parent's
committed cutoff at the commit decision is `conflict`. An absent or null
`expected_cutoff` is unconditional. The root is provisioned with the
hierarchy and advertised by `/describe`; `create` makes descendants only.

The create operation-ID scope is the parent: `(root_id, parent, "create",
operation_id)`. A repeated request with an equal normalized body returns the
original result with `replayed = true`, even after the parent's journal grew;
a different body under the same scoped identifier is `idempotency_mismatch`.
Receipt replay precedes re-evaluation of `expected_cutoff`.

### resolve

`resolve(space)` returns the space of an exact identity, with its path;
an unknown identity or root is `not_found`. `resolve(root_id, path)`
returns the current occupant of a path under current committed topology, or
`null` when the path is unoccupied — absence is an ordinary answer to "who
is here". Exactly one form is given. Resolution never creates a space.

### children

`children(space, through?, cursor?, limit?)` enumerates the immediate
children visible at the cutoff `through`: the fold of the parent's creation
entries at positions ≤ `through`, in creation order, each with its key, its
space and the creation entry. When neither `through` nor `cursor` is given,
the current committed cutoff is captured once. An explicit `through` must be
a cutoff of that journal: an interior position or a future position is
`invalid`, never rounded. Pages enumerate exactly the selected set, each
child once; a cursor binds the space and the cutoff, and an explicit
`through` that disagrees with a cursor is `invalid`. Later creations cannot
enter an earlier observation. An existing space with no children returns an
empty list with its cutoff; an absent space is `not_found`.

Enumeration obeys the page's inspection budget; non-creation entries are
nonmatches. A non-final page returns an advancing `next_cursor`, never
passing an undelivered child; its absence means the entire selected set has
been enumerated. An empty page may carry an advancing cursor.

### head

`head(space)` returns the committed cutoff from one committed snapshot: 0 for
an empty journal, else the end of the latest batch. It does not lock the
journal. An unknown space is `not_found`.

### append

`append(space, facts, operation_id, expected_cutoff?)` appends the supplied
non-empty batch, in input order, as one atomic mutation. Every fact receives
a position; every entry has origin `append`; `cutoff` equals `last`. No
partial batch becomes visible. The service validates the representation and
the resource bounds only.

If `expected_cutoff` is absent or null, the append is unconditional; if an
integer is present it must equal the current cutoff at the commit decision,
and `0` requires an empty journal. A mismatch is `conflict`, with the
expected and actual cutoffs, and appends nothing. Concurrent successful
appends to one journal have one committed order and occupy non-overlapping
contiguous ranges. There is no ordering guarantee between journals.

The append operation-ID scope is the space: `(root_id, space, "append",
operation_id)`. For an existing successful receipt the service compares the
normalized request and returns the original result with `replayed = true`;
the returned bounds are the original receipt's, even if later appends exist.
A different normalized request under the same scoped identifier is
`idempotency_mismatch` and appends nothing.

### read

`read(space, after?, through?, limit?, filter?)` returns one page of the
fixed prefix (`after`, `through`] of one journal, ordered by position.
`after` defaults to 0 and is exclusive. `through` is inclusive; when omitted
it is captured from the current cutoff once. An explicit `through` must be a
cutoff of that journal — 0 or the end of a committed batch: an interior or
future position is `invalid`, never rounded. The request must satisfy
`0 ≤ after ≤ through ≤ current cutoff`; a continuation (`after > 0`) must
resend `through`, since omitting it would select a new cutoff. `limit`
defaults to 64 and is at most 128.

A page stops at its output bounds (entries and fact bytes) or its inspection
bounds (candidates and fact bytes examined). A position is **accounted for**
only when its entry was returned or established not to match; an inspected
match that does not fit the remaining page is not passed — the next page
delivers it. `scanned_through` is the last accounted position, satisfying
`after ≤ scanned_through ≤ through`. Every non-final page advances beyond
`after` and returns `next_after = scanned_through`; a final page omits
`next_after`, and `scanned_through` then equals `through`. With
`after = through`, including an empty journal at 0, the page is empty with no
continuation. Entries is always an array.

To reconstruct a stable prefix, keep the first response's `through` and pass
it on every continuation, using `next_after` as `after`. Concurrent appends
raise the current cutoff but cannot change the selected prefix. There is no
server-side cursor state for reads.

### filters

A filter is a conjunction of structural conditions, compared by Ontos
equality, inferring nothing: `predicate`, which must encode an atom;
`arguments`, each requiring the argument at `index` (0 is the first argument
after the predicate) to equal a canonical value — an index outside the declared
tuple-arity domain is `invalid`, since no value under the profile has one, and
a request that cannot match is a malformed coordinate rather than an empty
result; `origin`; and `positions`,
bounding matches to (`after`, `through`]. Filtered results keep their
original references, order and batches. Each encoded filter value obeys the
fact value bounds.

The normalized filter — every member present, absent predicate and origin as
null, conditions sorted by index then by unsigned canonical bytes with
identical conditions collapsed, absent position bounds as 0 and `2^53-1` —
has a digest: the normalized JSON document projected through
`ontos-data-json/1`, wrapped as `Tuple(Atom("ulab.journal.filter.v1"),
document)`, encoded with `ontos-codec-v1` and hashed with SHA-256 over the
raw codec bytes. Query cursors bind that digest.

### query

`query(space, filter?, max_depth?, snapshot?, cursor?, limit?)` reads the
space and its descendants over an explicit **snapshot** — a recursively
complete selection of journal prefixes. Without a snapshot the service
captures one before returning any page:

1. record the selected space's cutoff;
2. derive its children at that cutoff;
3. record each child's cutoff and repeat, within `max_depth` measured from
   the selected space at depth 0 (`0` selects only that space; absent or
   null selects all descendants);
4. complete the manifest within the space-count, canonical-JSON-size and
   topology-inspection bounds, and within a complete continuation's request
   size, or refuse `limit_exceeded` — never a partial manifest.

The manifest lists **every** covered space in depth-first preorder by child
creation — the scope first, then each child's entire selected subtree —
including intermediate spaces and empty journals, so "no matches" and "not
examined" are distinguishable. The normalized choice of depth is always
retained in `snapshot.max_depth`. Entries follow the manifest's space order
and position order within each space, each with its reference, origin and
batch. Pages obey the same output, inspection and accounted-for rules as
`read`; an empty page may carry an advancing cursor, including progress
across completed empty spaces; no cursor means every selected prefix is
exhausted.

A continuation resubmits the manifest, the unchanged filter and the cursor.
The request's `space` must equal `snapshot.scope`; an omitted `max_depth`
uses the manifest's value and an explicit one must equal it. The cursor
binds the manifest's digest, the filter's digest and the traversal position;
any disagreement is `invalid`. A retained manifest can restart a query
without a cursor, including with a different filter.

A submitted manifest is validated against the creation records: it lists the
scope exactly once and, below the depth bound, every child visible at each
listed parent's cutoff, in creation order; no duplicate, unreachable or
out-of-scope space; paths and identities agreeing with the creation records;
every cutoff 0 or a committed batch boundary of its journal. An inconsistent
manifest is `invalid`; a listed space that does not exist is `not_found`.
Another internally valid selection, with older cutoffs, is accepted: a
manifest is an explicit input, not an attestation of who captured it.

The snapshot's digest follows the filter's construction with the tag
`ulab.journal.snapshot.v1` over the normalized manifest (every path an array,
`max_depth` present). The result is reproducible over an explicit set of
prefixes; it is **not** a simultaneous global snapshot. The provider may
serve from indexes derived from its own journals; consumers never mirror
facts into ancestors.

## Retry, failure and persistence

Successful mutation receipts are retained for the lifetime of their scope
and are returned through retries, never through a separate lookup. The
normalized body compares semantic values: facts as bytes, order significant,
the effective precondition included, absent and null preconditions the same,
explicit zero different; JSON whitespace and member order are immaterial.
Rejected requests reserve no operation IDs.

A successful mutation acknowledges the complete atomic commit, including its
receipt. Reads observe only committed state. Cancellation before commit can
prevent a mutation; cancellation, timeout, a lost reply or a service failure
does not prove that it did. Resolve uncertainty by retrying the same scoped
operation ID and exact request against the same continuing history; never
switch to a new operation ID because a response was lost.

Identity continuity preserves acknowledged history, not only identifiers: a
supported restart or restore serves the same `root_id`, `space_id`s,
positions, origins, batch boundaries, receipts and cursors, and the same
identity never exposes divergent committed histories. A public export through
`read` is not a provider migration. The in-memory engine of this lane
retains nothing across a restart and says so; the durable provider's
guarantees are stated with its acceptance.

## Refusals

| Code | Meaning and stable details |
|---|---|
| `invalid` | Malformed or out-of-range input; `details.field` names the input when available. An interior or future cutoff, a continuation that does not fit its request, and an inconsistent manifest are invalid |
| `not_found` | The root or exact space does not exist; `details.subject` names the missing selection |
| `exists` | The child key is occupied under that parent; `details.key` |
| `conflict` | The precondition failed; `details.expected_cutoff` and `details.actual_cutoff` are integers |
| `idempotency_mismatch` | A successful receipt exists for the scoped operation ID with a different normalized request |
| `limit_exceeded` | A declared resource bound would be exceeded; `details.limit` names it |
| `unavailable` | The engine, a selected history or a required observation cannot currently be served |
| `internal` | An unexpected service failure prevented a normal result; a mutation's outcome may be uncertain |

Every operation can refuse `invalid`, `unavailable` or `internal`. Every
operation that selects a space can refuse `not_found`. `create` additionally
refuses `exists`, `conflict` and `idempotency_mismatch`; `append` refuses
`conflict`, `idempotency_mismatch` and `limit_exceeded`; `children`, `read`
and `query` refuse `limit_exceeded`. Clients decide from the code and the
documented details, never from message prose.

## Authority

This implementation is an identity-blind backend. It neither authenticates
callers nor enforces access rules: any caller that can reach its listener
can create spaces and read or append to any space whose identity it knows.
Payload bytes that resemble identities, signatures or grants receive no
privileged treatment. Deployment boundaries and an explicitly selected higher
layer — Dawn, in the ULab deployment — provide access control. Space and
root identities, health checks and possession of a receipt grant no authority.
