The protocol
Six bytes of greeting, a five-byte frame header, five frame kinds that are never renumbered — and a published specification with a conformance corpus.
The wire protocol is specified in a repository of its own, with a conformance corpus, so that a client can be written by somebody who has never read the engine's source. This page is the shape of it; the specification is the authority.
The greeting
TCP. On connect, both sides send six bytes before anything else:
The magic is judged on its own four bytes, before the version bytes are read. A peer that is not a node owes nothing — it may send three bytes of an HTTP request line and hang up. A client that waited for all six would report that as a truncated stream, which sends whoever reads the error to the network, when the answer is that the address is wrong.
| what was read | the client says |
|---|---|
not TESS | not this protocol — and closes |
| a major it does not implement | wrong version, carrying both numbers — and closes |
| a differing minor | nothing; it keeps the peer's minor |
A differing minor is not a refusal. It is used for exactly one thing: deciding not to send what an older peer cannot read. It never gates decoding, because decoding is already safe.
Both refusals happen at the greeting and never mid-conversation, because a version mismatch discovered later arrives as a decode failure that reads like corruption.
Frames
kind 1 byte
length 4 bytes, big-endian — the body length, not counting this header
body `length` bytes
Five bytes of header, and a 16 MiB ceiling on the body. A declared length above the ceiling is refused before anything is allocated: a length from a stranger is not a promise, and allocating on one is the oldest denial of service there is. The ceiling applies on the way out as well as in — a peer that emits what it would refuse to read is running two protocols.
An answer above the ceiling is a refusal, class invalid, naming its size
(AnswerTooLarge, node 0.34.0-beta), and the connection goes on to the next
request. Up to 0.33.4-beta the node failed the write and closed the
connection, which a client could only report as a truncated stream.
Reading zero bytes between frames is a clean goodbye. Reading zero bytes inside a header or a body is truncation, and an error.
| tag | kind | direction |
|---|---|---|
| 1 | Request | client → node |
| 2 | Answer | node → client |
| 3 | Refusal | node → client |
| 4 | Subscribe | client → node |
| 5 | Change | node → client |
| 13 | Elsewhere | node → client |
| 17 | Vault | client → node |
| 37 | Progress | node → client |
| 38 | Part | node → client |
The tags are numbered explicitly and never renumbered, because a byte that once meant one thing cannot be asked about after the fact by a client of a different build.
An unknown frame kind closes the connection rather than being skipped. A protocol that ignores what it does not understand is one where a version mismatch looks like silence — and silence is the hardest failure to attribute.
The Vault frame (since node 0.17.0-beta, protocol minor 2) carries the four
acts on a vault's key — status, unseal, seal, change the
passphrase — with the passphrase as a field rather than as script text, and a
target that is either the store or one vault declared with a passphrase of its
own. The node answers with one value, the seal status
{ state, seals_at, unseal_for, custody }, or with a refusal whose text never
contains a passphrase. It is the one frame an older node cannot read, so a client
checks the peer's minor from the greeting and refuses to send it to a node below
minor 2 rather than have the connection closed.
The Elsewhere frame (protocol minor 1) is a redirect: the request was not
answered here, and the frame says where it would be. Its body is the node to
expect (16 bytes), the epoch that node leads under (a big-endian u64), one byte
saying whether the redirect is settled (1) or transient (2) — any other value
is malformed — and the address, as length-prefixed text. A node sends it only to a
client that greeted with minor 1 or later; an older client gets the refusal naming
the address instead. How a client follows it — three hops, no older epoch, the
session's USE replayed, the node checked on arrival — is the same in every
client, and the cluster pages
describe it; the protocol's own §3.12 states it step by step.
The Refusal frame (protocol minor 3, node 0.30.0-beta) carries a class
before its words: one byte naming what the caller should do next, then the
store's own message as UTF-8. The words are for a person and change between
releases; the class is what code branches on, and the set is closed for 1.x.
| byte | class | the caller should |
|---|---|---|
| 1 | invalid | fix the request; repeating it cannot succeed |
| 2 | unauthenticated | sign in, or sign in again |
| 3 | forbidden | stop; signing in again will not help |
| 4 | throttled | wait, then repeat |
| 5 | elsewhere | send it to the node the message names |
| 6 | retry | run the transaction again from its start |
| 7 | conflict | re-read: the state it assumed is not the state there is |
| 8 | unavailable | try later or another node; the request was fine |
| 9 | internal | report it: a defect, damaged data or an unreadable format |
A node sends the byte only to a client that greeted with minor 3 or later; an
older client gets the words alone, exactly as before. The body has no length
prefix, so a reader decides by the first byte: 0–9 is a class, anything else
is the first byte of the words from a node before 1.3, which carries none. 0,
and any class a client does not know, is unknown — treated as not retriable.
Following changes
A Subscribe frame names the first log position to read, inclusive, and
optionally one table; after it the connection delivers Change frames and no
longer answers statements. A client stores the sequence of the last change it
handled and resumes with that plus one — the arithmetic every client in this
section owns so that you do not.
A feed over a split table
reads several logs that count separately, so a change there also carries a
cursor, sent after everything else in the frame. A client resumes such a
feed by sending the cursor of the last change back at the end of its Subscribe
frame, and the node resumes after that change. A frame without it means what it
always meant, so a feed over an ordinary table is unchanged — but a client MUST
accept the extra bytes, or it fails the first time anybody follows a split
table. The clients carry it from 0.2.0.
Narrowing a feed by a condition
From protocol 1.4 (node 0.33.0-beta), a Subscribe frame over one table may also carry a condition, as
TessariQL text, and the values its parameters are bound to after the node reads it. The feed then sends:
- a change that matches, as it is;
- a change to a record that matched just before and no longer does, or that was removed, as a removal.
A mirror applying the feed holds exactly the matching records. The condition is judged on what the subscriber may
see; one naming a field they may not see is refused (FieldNotVisible). A narrowed feed that skipped changes sends
a Progress frame (tag 37) carrying the last sequence it read, and its cursor on a split table. Store it as you
store a change's position. A client sends a condition only to a node whose greeting says minor 4 or more: an older
node would ignore it and send everything.
Streaming a snapshot
From protocol 1.5 (node 0.34.0-beta) a Request may end with one more byte, the
stream byte: 1 asks for the snapshot the script takes to arrive as Part
frames (tag 38) ahead of the answer, so a backup has no size ceiling. A Part is 1
to 16 MiB of the snapshot file, in order, with no header of its own; the node
sends parts of 1 MiB. The request then ends on the frame it always ends on:
- an Answer — the parts are the whole snapshot, and the
BACKUP STATEoutcome is its summary,{ form, records, version }; - a Refusal — the parts are not a snapshot, and the client discards them;
- an Elsewhere — as without streaming.
Only the first snapshot in the script streams; BACKUP LOG and BACKUP SCRIPT
are answered whole. A client sends the stream byte only to a node whose greeting
says minor 5 or more — an older node reads past it and answers the snapshot as a
value — and a node sends a Part only to a request that carried the byte, so no
older client ever meets the tag. The byte's vectors are in the corpus's
frames-v1.json.
Two version numbers, promising different things
major and minor, one byte each, and the greeting carries both. A differing
major is refused there; a differing minor proceeds, and each side keeps the
other's.
What separates them is what a client can skip. Every outcome in an answer
carries its own length, so a client meeting an outcome kind it does not know can
step over it and go on reading — which is what makes a new outcome kind a
minor change, and it is why the length is there at all.
A new value type is not skippable that way. A value nested inside an array,
an object, a set or a range follows the previous tag with no length in front of
it, so an unknown tag inside a collection ends the parse with nowhere to resume.
So a new value type is a major change, and the asymmetry is a limit of the
encoding rather than a preference: length-prefixing nested values would cost four
bytes per element — six kilobytes on one 1536-dimension embedding — against no
benefit anybody has named.
Values
Every value crosses in the store's own encoding, tagged by type. That is what lets a bound parameter arrive as the value it is rather than as text the node has to read — the property parameters rest on, held across a socket.
Geometry, ranges, record ids, decimals and durations all have their own tags. A client that mapped them onto JSON's six types would have to decide, once, what to do about the eleven that do not fit — which is why the CLI prints answers in TessariQL's own syntax and not in JSON.
Over a WebSocket, for a browser
A browser cannot open a TCP socket, so the node carries the same protocol over a
WebSocket too: GET /wire on the HTTP port upgrades, and from then on the
socket carries exactly the bytes described above — the greeting, then frames —
with nothing added.
- Binary messages are the byte stream. Their boundaries mean nothing: a frame may arrive in three messages, or three frames in one, and a reader reassembles as it would from TCP.
- A text message is not part of it, and the node closes with
1003. One message may be at most 16 MiB and 5 bytes — a frame at the ceiling with its header — and a larger one is closed with1009. - It is a wire session in every respect: the greeting deadline, one session
per connection, subscribing consumes it, and the node's connection ceiling
counts TCP and WebSocket sessions together — a full node answers the upgrade
503, and a node started without a wire address answers404. - Credentials travel in the Request frame, exactly as over TCP. An
Authorizationheader and cookies on the upgrade are ignored, because a browser attaches both to a socket opened by any page.
What is deliberately not in the protocol
No compression, no multiplexing, no server push outside a subscription, and no schema negotiation. Each of those would make a conforming client substantially harder to write, which is the cost this specification is trying not to pay.
TLS is not in the protocol either, and that is the point: a node given a
certificate (from 0.21.0-beta) wraps the whole connection in TLS 1.3, and inside
it every byte is the protocol as specified, so TLS changes no version and no
frame. A client verifies the node's chain and name, offers no way to skip either,
and never falls back to the clear — the rules are in the specification's TLS
section.
Writing a client
The specification and its conformance corpus are published, and the corpus is generated rather than hand-written, so a client can be checked against the same vectors the engine is. That is the intended path: implement against the specification, pass the corpus, and never read the engine to find out what it really does.