TessariDBDocs
  • 0.34.2-betalatest
  • 0.34.1-beta
  • 0.34.0-beta
  • 0.33.4-beta
  • 0.33.3-beta
  • 0.33.2-beta
  • 0.33.1-beta
  • 0.33.0-beta
  • 0.32.0-beta

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:

"TESS" major minor 4 bytes 1 byte 1 byte
Judged in two halves: the magic first, the version after it.

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 readthe client says
not TESSnot this protocol — and closes
a major it does not implementwrong version, carrying both numbers — and closes
a differing minornothing; 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.

tagkinddirection
1Requestclient → node
2Answernode → client
3Refusalnode → client
4Subscribeclient → node
5Changenode → client
13Elsewherenode → client
17Vaultclient → node
37Progressnode → client
38Partnode → 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.

byteclassthe caller should
1invalidfix the request; repeating it cannot succeed
2unauthenticatedsign in, or sign in again
3forbiddenstop; signing in again will not help
4throttledwait, then repeat
5elsewheresend it to the node the message names
6retryrun the transaction again from its start
7conflictre-read: the state it assumed is not the state there is
8unavailabletry later or another node; the request was fine
9internalreport 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 STATE outcome 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 with 1009.
  • 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 answers 404.
  • Credentials travel in the Request frame, exactly as over TCP. An Authorization header 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.

The protocol — TessariDB