Overview#
TERSL/DB speaks TERSL text over HTTP in both directions. A client sends declarations and queries in one request body to the Issue route and reads the query answers and any messages from one response body. Other routes read the history of a namespace, export or load a whole namespace, and serve the record's administrative operations: forks, seals, scrubs and playback.
This page describes the request and response format, the routes, and the rule that decides when an answer is a status code and when it is text. The headers, status codes and diagnostic codes pages list every value in detail.
The examples use {host} for the address of the HTTP API that your account is served from. Namespace names in the examples, such as 7000001, stand for the Namespace Id the portal shows for your namespace.
Base path and namespace names#
Every namespace route is under /v1/ns/{ns}, where {ns} is the namespace's seven-character name, the Namespace Id the portal shows (see What TERSL/DB is). Names are matched exactly, so a lower-case spelling does not match. Names ending in _meta are reserved.
A name that is not one this service serves to the caller, including a _meta name, answers the empty 404 described under Status codes.
Authentication#
Send the key's secret in one of two headers:
Authorization: Bearer <secret>
Terse-Key: <secret>
When both are present, Authorization is used. An Authorization header with any scheme other than Bearer is refused; the service does not fall back to Terse-Key. The scheme name is case-insensitive.
A missing, unknown, revoked or expired key, a key for another account's namespace, and a key that the namespace's grants admit to nothing all receive the same answer: the empty 404, with no body and no service headers. A client cannot distinguish them from a namespace that does not exist. The reason is recorded in the account's log; see Logs. What a key may do once it is admitted is set by the namespace's grants; see Keys, grants and visibility.
One content type#
Every request and response body on a namespace route is text/plain; charset=utf-8. The one exception is /.well-known/terse-seal-keys, which answers a JWK set.
The service decodes a request body as strict UTF-8. A leading byte-order mark is accepted and removed. A body that contains an invalid UTF-8 sequence is refused with 400 and error: the request body is not valid UTF-8; no replacement character is ever stored.
When the same request header appears more than once, the last value is used.
The Issue route#
POST /v1/ns/{ns}/issue is the route for reading and writing state. Its body has two halves separated by a seam line:
- Declarations — TERSL declarations to apply. See Writing with declarations.
- The seam line — a line consisting exactly of the seam token,
%%%SEAM%%%by default. - Queries — query lines to answer after the declarations are applied. See Reading with queries.
The body is split at the first line that is exactly the seam token, with no trimming and no case folding. Line endings are normalised to LF first, so a client that sends CRLF frames identically. The seam line belongs to neither half.
| Body shape | Meaning |
|---|---|
| No seam line | The whole body is declarations. |
| Seam on the first line | Queries only. |
| Seam on the last line | Declarations only. |
| Empty body | Nothing to do; the answer is still a 200 and still reports Terse-Csn. |
To use another token, send Terse-Seam: <token>. A usable token is non-empty and contains only printable ASCII from ! to ~ (U+0021 to U+007E): no spaces, tabs or line breaks. An unusable token is a 400 that names the header.
The answer#
The response body has three parts, framed with the same token the request used:
- The query answers, in TERSL.
- The seam line. It is always present, even when both other parts are empty, so a complete response cannot be mistaken for a truncated one.
- The Messages channel.
If a query answer contains a line equal to the seam token, the service cannot re-frame the response. It adds warn: the response contains a line equal to the seam token '…'; re-issue with a different Terse-Seam header.
The declarations commit first, in one step, under one change sequence number (CSN). The queries run afterwards against the result, so a query in the same Issue reads the declarations it was sent with. Terse-Csn on the response is the namespace's head CSN after the declarations.
The Messages channel#
The Messages channel is one line per message, in the order the messages arose: declarations messages first, then queries messages, each in line order. Each line starts with a severity word, error:, warn: or info:, and optionally a channel and line number:
error: queries line 3: …
error: declarations: …
warn: …
info: duplicate of csn 7; not re-applied
Line numbers are counted per channel. Declarations line 1 is the first line of the body; queries line 1 is the first line after the seam. Blank lines count. A message that contains a line break is written on one line with the break shown as |. Language diagnostics keep their own code, for example error: queries line 4: TERSE904: ….
Terse-Diagnostics: errors=N; warnings=M counts the error: and warn: lines in the body. info: lines are not counted. A client that does not read the body should read this header.
The queries channel#
The queries half accepts query lines only, and a restricted read vocabulary. The leading ? is optional. The directives a query line may carry are WHERE, WHEN, CONTAINS, DEPTH, FIRST n, LAST n, CONTAINERS and OBJECTS, where n is greater than zero.
The channel never changes state. Anything else on a query line is removed and reported, and the rest of the line is answered:
| What the line contains | What happens |
|---|---|
Any other directive, including [FIRST] or [LAST] with no count |
The directive is removed with a warn: line. |
| An attribute list | The list is removed with a warn: line. |
| A directive tail the parser refuses as a whole | The line is answered as the bare read, without its tail. |
| A container header, a declaration or a text line | The line is ignored with a warn: line. |
| A second seam line | The line is ignored with a warn: line. |
| A line that cannot be read as a query | error: queries line N: the line could not be read as a query and was ignored |
Nothing in the queries channel stops the batch: each line is answered or reported on its own.
The status doctrine#
An Issue the service processed is 200, whatever happened to its lines. A write refused by the grants (TERSE-SEC-001), an Expected-Csn mismatch, a declaration the language refused and a query that could not be read are all reported in the Messages text of a 200. HTTP status is used for transport (400), addressability (404), availability (503, 504), shape (413), the account and its plan (403, 429, 507), a read-only namespace (409), a write that cannot be committed as sent (422), and a write whose outcome is unknown (500). A caller retries a 503 or 429 unchanged; it does not retry a refusal unchanged. Every refusal that is a status carries one or more error: lines in its body; the two exceptions are listed under Status codes.
A key that may not read part of the namespace gets an answer without that part; the omission is not reported.
Addresses: reading a moment#
A namespace name may carry a point in its history: {ns}@{csn} or {ns}@{time}, where {time} is RFC 3339 with an offset, for example 7000001@12 or 7000001@2026-10-20T09:00:00Z. The address is another spelling of the Terse-As-Of-Csn and Terse-As-Of headers and follows the same rules: a CSN past the head is the head, and a time selects the newest entry committed at or before it.
The Issue, history, explain, export, fork and playback-open routes accept an address. A read at a point is queries only: an Issue with declarations beside an address or an as-of header is refused with 400 and error: an Issue with Terse-As-Of-Csn or Terse-As-Of is queries only; the past is read-only. Sending the address and a header together is a 400. What a key sees at a point is decided by its grants as they are now. See The record: history, moments, copies.
Routes#
"Admin" means a key granted all at the namespace root. A caller who is not admitted to a route receives the empty 404, including on a request with malformed parameters. A route the deployment does not offer also answers the empty 404.
| Method | Path | Who may call | Returns |
|---|---|---|---|
GET |
/healthz |
anyone, no key | ok |
GET |
/v1/ns |
any valid key | a TERSL # Namespaces container listing the namespaces the key reaches |
POST |
/v1/ns/{ns}/issue |
a key with at least one right in the namespace | query answers, seam line, messages |
GET |
/v1/ns/{ns}/history |
a key with at least one right; entries need history |
log entries as a .terse document |
GET |
/v1/ns/{ns}/explain |
admin | which grants governed a key at a CSN, as text |
GET |
/v1/ns/{ns}/export |
a key with at least one right | the visible state as a .terse attachment |
POST |
/v1/ns/{ns}/load |
a key that can write under every root the file writes | a streamed report, one line per phase and batch |
POST |
/v1/ns/{ns}/fork |
admin | 202 and the fork's document |
GET |
/v1/ns/{ns}/fork |
admin | every fork of the namespace |
GET |
/v1/ns/{ns}/fork/{fork} |
admin | one fork's document |
GET |
/v1/ns/{ns}/seal |
a key with history over the whole namespace |
a seal document |
GET |
/.well-known/terse-seal-keys |
anyone, no key | the JWK set that verifies seals |
POST |
/v1/ns/{ns}/scrub |
admin | 202 and the scrub document |
GET |
/v1/ns/{ns}/scrub |
admin | the scrub document |
POST |
/v1/ns/{ns}/playback |
admin | 201 and a new session's document |
GET |
/v1/ns/{ns}/playback/{id} |
admin | the session's document |
POST |
/v1/ns/{ns}/playback/{id}/step |
admin | the step's document |
DELETE |
/v1/ns/{ns}/playback/{id} |
admin | the closed session's document |
Every namespace route can also answer 403 for a suspended account, and 429 or 503 when a rate or capacity limit applies.
Listing namespaces#
GET /v1/ns answers a TERSL document: # Namespaces, then one name per line, sorted. It lists every namespace the key is admitted to; a listed namespace may hold nothing.
History#
GET /v1/ns/{ns}/history takes four optional query parameters:
| Parameter | Value |
|---|---|
from |
The first entry: a CSN (a non-negative integer) or an RFC 3339 time with an offset, which selects the first entry at or after it. |
to |
The last entry: a CSN, or a time, which selects the last entry at or before it. Clamped to the head. |
limit |
The most entries to return: a positive integer, default 100, at most 1,000. |
path |
A dotted path that narrows the answer to a subtree. The root by default. |
The answer is the entries in ascending order, concatenated in the log format: a valid .terse document. When the range was not consumed, Terse-History-Next gives the CSN to send as from for the next page. Terse-History-Verified reports the chain check for the entries read: ok, unchained, violation or violation csn=N.
An entry the key cannot see any of is omitted and not counted. An entry the key can see part of is returned with only the visible lines and without its chain lines. A key with read but not history receives an empty 200, the same answer as an empty range. On an address, the address's point is the end of the range; an explicit to later than the point is a 400. A bound that is neither a CSN nor an RFC 3339 time with an offset is a 400 that names the parameter.
For a page a client can verify against Terse-Chain, read with a key whose history covers the whole namespace and send no path.
Explain#
GET /v1/ns/{ns}/explain?key=&csn=&path= answers, as text, what the key named by its id in key= was permitted at the given CSN under the grants that governed that entry. The CSN may come from csn= (at least 1) or from an address, not both. A time before the first entry is a 422.
Export#
GET /v1/ns/{ns}/export returns the namespace's state as the key sees it, the same document a bare ? query returns, as an attachment: Content-Disposition: attachment; filename="<ns>-<csn>.terse". An address, such as 7000001@12, exports that moment. The file begins and ends with a comment line:
// TERSL export namespace 7000001 csn 12 utc 2026-10-10T04:12:09Z
…the state…
// TERSL export complete containers 1,204 objects 9,881 lines 12,377
Loading the file back produces the state and nothing else. A file without the closing line was cut short. The response streams; if the read fails after the first byte, the service closes the connection without completing the response. Export is not subject to the request time limit.
Load#
POST /v1/ns/{ns}/load writes a large TERSL file into a namespace as a series of batches. Each batch is applied as an ordinary Issue with its own CSN, so each batch commits as a unit, and batches committed before a stop stay committed. A load adds and modifies what the file names; it never removes anything the file does not name. The body is declarations only: a ? line, a wildcard header or a splice anywhere in the file refuses the whole load before anything is applied. The Terse-Load-* headers, Expected-Csn (first applied batch only) and Terse-Idempotency-Key control it; see Headers.
Refusals that need no body — authentication, suspension, a malformed header, a namespace already loading, a read-only namespace, a declared Content-Length over the deployment's load limit, a rate or capacity limit — answer a real status before the body is read. After that the service starts a 200 and streams one line at a time. Progress lines (spooling N bytes…, planning…, applying batch k of N…, pausing…) are written whenever the stream has been quiet for a while; they report progress and record no outcome. The record lines are:
load 2c1acf997612 ns 7000001 bytes 20,972,450 lines 361,592 batches 3 budget 8,388,608 exclusive none
batch 1/3 lines 1–144,632 bytes 8,388,707 csn 1 33407 ms diagnostics 0
paused 30 s: tenant throughput limit (actor-time)
paused 30 s: tenant throughput limit (actor-time)
paused 6 s: tenant throughput limit (actor-time)
batch 2/3 lines 144,633–289,252 bytes 8,388,664 csn 2 52061 ms diagnostics 0
paused 30 s: tenant throughput limit (actor-time)
paused 30 s: tenant throughput limit (actor-time)
paused 30 s: tenant throughput limit (actor-time)
paused 14 s: tenant throughput limit (actor-time)
batch 3/3 lines 289,253–361,592 bytes 4,195,079 csn 3 55789 ms diagnostics 0
done batches 3/3 lines 361,592/361,592 bytes 20,972,450 csn 1..3 wall 310.1 s
committed through line 361,592
Messages under a batch use the file's line numbers. A load whose file the service refuses before applying anything writes its error: lines, then refused csn none wall N s reason <code> and committed through line 0, inside the 200. A batch the service refuses stops the load, unless Terse-Load-On-Error: continue applies to it. The last line, committed through line L, is always the last file line that this run committed.
To resume after a stop, send the same file with Terse-Load-Skip-Lines: L, or send it with the same Terse-Idempotency-Key: batches that already committed are answered from the record (csn C duplicate) instead of being applied again. Prefer the key when a batch's outcome is unknown. Terse-Load-Dry-Run: true plans the file and reports the plan without applying anything. The load route is not subject to the request time limit. The portal's Bulk export and load page uses these routes.
Fork#
POST /v1/ns/{ns}/fork?csn=<n>&name=<display name> (or at=<time> in place of csn, or an address in place of either) starts a copy of the namespace as it stood at that point. name is required, at most 200 characters. grants=current (the default) gives the copy the source's current grants; grants=asof gives it the grants that governed the point, and falls back to the current grants, with the reason in the document, when that cannot be determined.
The answer is 202 with Location: /v1/ns/{ns}/fork/{fork}, Terse-Csn: n and the fork's document, a TERSL container # Fork <name>(…) whose state is queued, copying, anchoring, active or failed. Poll the Location until state is active or failed. The copy is a new namespace, created read-only; its admin lifts that in the portal. A fork counts toward the plan's namespace limit: over it, the answer is 409 with this plan allows N namespaces; delete one to create another. A point before the first entry is a 422 with there is nothing to fork at that point; the first entry is csn 1. A full queue is a 503 with Retry-After.
Seals#
GET /v1/ns/{ns}/seal returns the newest seal; ?csn=<n> returns the seal at that CSN. Terse-Csn is the seal's CSN. When there is none, the answer is a 404 with a body: error: namespace X has no seal at csn N; the newest checkpoint is csn C. GET /.well-known/terse-seal-keys returns the public keys as application/jwk-set+json, with Cache-Control: public, max-age=3600, and needs no key. Terse-Seal-Csn on other answers names the newest seal at or below the CSN answered.
Scrub#
POST /v1/ns/{ns}/scrub queues a check of the namespace's recent checkpoints and seals and answers 202, Location: /v1/ns/{ns}/scrub and the scrub document. GET on the same path returns the document: a # Scrub container whose state is queued, running, done, failed or interrupted, with one entry per checkpoint examined. A checkpoint status other than ok and seal pending is a finding. A namespace never scrubbed answers 404 with error: namespace X has never been scrubbed; …. A full queue is a 503 with Retry-After.
Playback#
A playback session steps through a namespace's history one entry at a time and checks each entry and each checkpoint it crosses. POST /v1/ns/{ns}/playback?csn=<n> (or at=<time>, or an address) opens a session at that point and answers 201 with Location: /v1/ns/{ns}/playback/{id}. Every playback answer carries Terse-Playback: <id> and Terse-As-Of-Csn: <position>.
POST /v1/ns/{ns}/playback/{id}/step moves the session: n=<k> entries forward (default 1), to=<csn> or at=<time>. A target earlier than the position reopens the session there. verify=none skips the checkpoint checks; verify=on is the default. A step larger than the deployment allows, or a point too far from a checkpoint, is a 422; a limit on sessions is a 409. DELETE closes the session. A session closes itself after a period without use and does not survive a restart of the service. The id is not a credential: any admin of the namespace may drive the session, and an unknown or closed id answers the empty 404.
Example: an Issue#
The request writes a container and an object, then reads them back:
POST /v1/ns/7000001/issue HTTP/1.1
Host: {host}
Authorization: Bearer <secret>
Content-Type: text/plain; charset=utf-8
Terse-Agent: harbour-sync/1.0
# Places
## Harbour(open)
boat
%%%SEAM%%%
? Places.Harbour
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Terse-Csn: 13
Terse-Diagnostics: errors=0; warnings=0
Server-Timing: parse;dur=0.3, apply;dur=4.1, query;dur=0.7, total;dur=5.2
# Places
## Harbour(open)
boat
%%%SEAM%%%
The Messages channel is empty, so the body ends with the seam line. A namespace with a verifiable record also returns Terse-Chain, and an account with plan rates returns the RateLimit-Policy and RateLimit headers.
If the key may not add Places.Harbour, the same declarations, sent without the query line, answer 200 and change nothing:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Terse-Csn: 12
Terse-Diagnostics: errors=1; warnings=0
Server-Timing: parse;dur=0.2, apply;dur=1.9, query;dur=0.0, total;dur=2.2
%%%SEAM%%%
error: declarations: TERSE-SEC-001: Add at 'Places.Harbour' is not permitted
Example: a point-in-time read#
The request reads Places.Harbour as it stood at a time. The body starts with the seam line because it carries queries only:
POST /v1/ns/7000001/issue HTTP/1.1
Host: {host}
Authorization: Bearer <secret>
Content-Type: text/plain; charset=utf-8
Terse-As-Of: 2026-10-20T09:00:00Z
%%%SEAM%%%
? Places.Harbour
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Terse-Csn: 9
Terse-As-Of-Csn: 9
Terse-Diagnostics: errors=0; warnings=0
# Places
## Harbour
boat
%%%SEAM%%%
Terse-As-Of-Csn tells the caller which entry the time selected; Terse-Csn is the CSN served, not the current head. Server-Timing on a point-in-time read includes a fold entry, the time spent preparing the state at that point. POST /v1/ns/7000001@2026-10-20T09:00:00Z/issue with no Terse-As-Of header is the same request.