The Ranvier wire format
Normative specification of the bytes two Ranvier processes exchange. Write a binding in C#, Python or JavaScript from this document and it will interoperate with the Rust implementation in this repository without reading the Rust.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY in this document are to be interpreted as described in RFC 2119.
0. How to read this
This document is authoritative. Where it and any other document in this
repository disagree about the bytes on the wire, this one governs and the other
is the one to correct. docs/decisions/ records why a rule was chosen; it does
not state what the rule is.
Every requirement carries the file and line that establishes it, written as
path:line, or a check block whose output is asserted. Paths are relative to
the repository root. docs/docs.tools/check-spec.py resolves every citation and
runs every check block in continuous integration, so a citation that stops
resolving fails a build.
Line numbers move; the symbol named beside each citation does not. A citation that has drifted is a defect in this document — re-find it by grepping for the symbol, and fix the number here.
Requirement and accident are kept apart. Where the implementation does something nothing obliges it to do — the order it writes protobuf fields in, the size of its read buffer — this document says MAY and then says what the Rust does. Writing those down as requirements would ossify an accident, and a second implementation would be judged against a choice nobody made.
Where nothing has decided a behaviour, this document says so and stops. §8 collects every such case. A specification with visible holes is more use than one that guesses, because an implementer can see which decisions are still theirs.
Scope. Two channels use this framing: the data plane, where samples travel
between nodes (§4), and the control plane, where a node
and its runtime talk (§5). Both sit directly on TCP. What
a sample's values bytes mean is not here: that is the schema the stream
announces, and the canonical schemas belong to CA3.
1. Framing
1.1 The problem the prefix solves
TCP delivers a stream of bytes with no message boundaries. A publisher that
writes two samples may have them arrive as two reads, as one, split down the
middle of either, or a byte at a time. Each of those is TCP behaving correctly.
On loopback with small messages the bytes almost always arrive in one piece,
which is why a decoder written against whole messages passes its tests and then
corrupts data on a real network (crates/message/src/framing.rs:5-15).
1.2 The frame
A frame is a four-byte length followed by that many bytes of payload.
+--------+--------+--------+--------+---------------------------+
| len | len | len | len | payload (len bytes) |
| MSB | | | LSB | |
+--------+--------+--------+--------+---------------------------+
- The prefix MUST be exactly four bytes (
crates/message/src/framing.rs:42, pinned to the literal4by the test atcrates/message/src/framing.rs:194). - The prefix MUST be an unsigned 32-bit integer in big-endian byte order —
most significant byte first (
crates/message/src/framing.rs:92writesto_be_bytes,:128readsfrom_be_bytes; the golden vectors at:198-209fail for any little-endian reading in either direction). - The prefix MUST count the payload alone. It does not include itself. A
writer emits four prefix bytes and then
lenbytes (crates/message/src/framing.rs:92-93); a reader takes the payload fromPREFIX..PREFIX + len(:139). - A payload of zero bytes MUST be accepted as a complete frame, and a reader
MUST NOT treat it as end of stream (
crates/message/src/framing.rs:302-310). This is not a curiosity: proto3 encodes a message whose every field holds its default as zero bytes, so00 00 00 00is what an empty message looks like.
grep -c 'pub const PREFIX: usize = 4;' crates/message/src/framing.rs
Big-endian here, little-endian inside. The frame prefix is big-endian;
protobuf's own fixed32/fixed64 fields are little-endian, by the protobuf
encoding rules rather than by anything this project chose. Both byte orders
appear in the worked example at §4.5, a few bytes
apart. An implementation that uses one byte order for both reads timestamps that
are wrong by many orders of magnitude and reports no error.
1.3 Reading
A reader MUST accumulate bytes and yield a frame only when the whole frame has arrived. Specifically:
- With fewer than four bytes buffered, yield nothing and wait
(
crates/message/src/framing.rs:123-125). Two bytes of a four-byte length is not a length, and treating it as one produces a plausible wrong number rather than an error (:284-299). - Read the length from the first four bytes.
- If the length is above the ceiling, fail — see §2.
- With fewer than
4 + lenbytes buffered, yield nothing and wait (crates/message/src/framing.rs:133-135). - Otherwise yield the payload and consume
4 + lenbytes (crates/message/src/framing.rs:137-145).
A reader MUST produce the same frames whether the bytes arrive one at a time
or a megabyte at a time (crates/message/src/framing.rs:248-269, which feeds three
frames byte by byte), and MUST yield every complete frame in a single read
before waiting for more (:272-281, where one read carries three frames).
A reader SHOULD bound the memory it holds by what has arrived rather than by
how long it has waited. The Rust reader holds at most one incomplete frame and
drains consumed bytes rather than reallocating
(crates/message/src/framing.rs:144, and the ten-thousand-frame test at
:364-401).
Bytes left buffered when a connection ends are a truncated stream. A reader
MUST NOT deliver a frame assembled from them
(crates/message/src/framing.rs:133-135 yields nothing). One implementation
stands behind this rule rather than two: every binding here sits on the same
Rust reader, so a reader wanting a second, independent opinion does not have one
in this repository.
1.4 Writing
A writer MUST emit the prefix and the payload as one logical unit and MUST
NOT interleave another frame's bytes between them. On a socket that accepts a
partial write, a writer MUST track how many bytes were actually written and
resume from there; re-sending the whole buffer repeats bytes already on the wire,
and the next length prefix is then read as payload — every frame after it
misaligned, on a connection that reports itself as healthy
(crates/runtime/node/src/transport.rs:395-436, which is what send_all exists
for).
A writer MAY coalesce several frames into one socket write. The Rust
publisher writes one frame per call
(crates/runtime/node/src/transport.rs:368-377) and disables Nagle's algorithm so
a small frame leaves when it is written rather than when the kernel has
accumulated a segment (crates/runtime/node/src/transport.rs:345-347). Disabling
Nagle is a MAY: it is insurance rather than a measured improvement, and the
measurement that failed to show an effect is recorded at
crates/runtime/node/src/transport.rs:325-336.
2. Limits
MAX_PAYLOAD is 16,777,216 bytes (16 MiB), pinned to that literal by a test
as well as by the constant (crates/message/src/framing.rs:39 and :195).
grep -c 'pub const MAX_PAYLOAD: usize = 16 \* 1024 \* 1024;' crates/message/src/framing.rs
- A reader MUST treat a prefix claiming more than
MAX_PAYLOADas an error and MUST NOT allocate on the strength of it (crates/message/src/framing.rs:130-132). The number came from the network and there is no reason to believe it; a hostile or corrupt four bytes would otherwise ask for four gigabytes. - The boundary is inclusive: a payload of exactly 16,777,216 bytes is legal and
16,777,217 is not (
crates/message/src/framing.rs:331-346, which pins the boundary with the four bytes01 00 00 00and01 00 00 01rather than with arithmetic on the constant). - A writer MUST NOT emit a frame whose payload exceeds
MAX_PAYLOAD, and MUST report the refusal to its caller (crates/message/src/framing.rs:86-90). The Rust writes nothing into the output buffer when it refuses (:349-360).
What an implementation does after a TooLarge error is per channel, and the
three readers in this repository do three different things. See
§6.
The ceiling is not negotiated, and since 2026-08-23 it is written once:
crates/message/src/framing.rs:39. It was written three times — the TypeScript
and Python clients each hardcoded it — and those two are deleted; a binding over
the C ABI inherits the ceiling instead of restating it. Two files still carry
the number without implementing the framing, and a change to it has to reach
them: conformance/write-encoding.py:348, which publishes it in the corpus, and
conformance/bindings/wire.py:54, which builds a prefix deliberately above it.
3. Topology
3.1 Two planes
Control traffic and data traffic MUST NOT share a channel.
- A published sample MUST travel directly from the publishing node to each subscribing node. No sample passes through the runtime: the registry is a directory consulted over the control plane at bind time, not a relay.
- Data MUST NOT travel on the control connection.
The two planes also dial in opposite directions, which is the part most easily got wrong:
| Plane | Who listens | Who dials |
|---|---|---|
| Control | the runtime | the node |
| Data | the publisher | the subscriber |
3.2 The control plane's topology
The runtime binds a loopback listener before it spawns anything, and one listener
serves the whole graph, so an accepted socket is anonymous until the node names
itself (crates/runtime/session/src/plane.rs:10-14).
The runtime MUST put four variables into every child's environment:
| Variable | Value |
|---|---|
RANVIER_CONTROL | host:port — the runtime's listener |
RANVIER_TOKEN | the connection token |
RANVIER_NODE_ID | the node's identity in the graph |
RANVIER_PARAMS | a JSON object of settings |
A node reads them at crates/runtime/session/src/launch.rs:57, :63, :69 and
:107. control.md §2 is authoritative on this contract, and says which of
the four a node MUST read rather than only what each carries — this table said
three until 2026-08-19, which is what a second copy of a rule costs.
The token is a separate variable rather than part of the address so that a
diagnostic can print where a node is dialling without printing the secret.
With RANVIER_CONTROL unset there is no control connection and the node is being
run by hand (crates/runtime/session/src/control.rs:6-11).
RANVIER_BINDINGS MUST NOT be used. Bindings arrive on the connection as
Bind messages (§5.2). A node that reads bindings from
the environment cannot be told an address nobody knew when the process started.
3.3 The data plane's topology
The publisher listens and the subscriber dials, on loopback and across
devices alike. One listening socket serves any number of subscribers
(crates/runtime/node/src/transport.rs:153-178), and reconnection lives in the
subscriber (:185-213).
A publisher is frequently an acquisition node, and an acquisition loop never waits — bookkeeping about who has gone away belongs anywhere but there. So a publisher MUST NOT track subscribers that have disappeared, and reconnection is the subscriber's responsibility.
On one machine a publisher binds 127.0.0.1:0 — an ephemeral port, deliberately.
The transport is TCP. Nothing else is implemented.
3.4 Crossing a device boundary
No connection may cross a device boundary in this version. Authentication and
encryption do not exist, and the control plane's token is a session secret passed
through the environment rather than a credential
(crates/runtime/session/src/plane.rs:642-653). This is a block rather than a
deferral: the work is not scheduled behind other work, it is waiting on a
security design that has not been done.
A cross-device publisher would use a fixed, configurable port rather than an ephemeral one, so that an administrator can be asked to permit it. The default port, the configurable range, and whether that range is per publisher or per host are all open — see §8.
4. The data plane
4.1 The conversation
subscriber → publisher Request { stream_name } exactly once, first
publisher → subscriber Frame { announce } once per stream
publisher → subscriber Frame { sample } repeatedly
Both message types are defined in proto/ranvier/message/v1/transport.proto and
encoded as proto3.
- A subscriber MUST send one
Requestas the first frame on the connection (crates/runtime/node/src/transport.rs:447-453). - A subscriber MUST NOT send any other frame on that connection. The
publisher reads exactly one frame and never reads the socket again
(
crates/runtime/node/src/transport.rs:221-223is the only read;serve_onewrites from there on). Bytes sent after theRequestare not read, and any that arrived in the same read are discarded with the reader (crates/runtime/node/src/transport.rs:349-366). - A publisher MUST NOT send anything before it has read the
Request(crates/runtime/node/src/transport.rs:221-238).
Request.stream_name is a name, not a stream. Several streams may publish
under one name, and a subscriber asking for a name receives all of them — exactly
as it would in the same process
(proto/ranvier/message/v1/transport.proto:37-41).
4.2 The announcement rule
A Sample carries opaque bytes and a timestamp on a clock nobody has named. The
StreamInfo is what makes both readable. So:
- A publisher MUST send
Frame { announce }for a stream before the firstFrame { sample }carrying that stream'sstream_ref, for every stream publishing under the requested name — not the first one it finds (crates/runtime/node/src/transport.rs:238-240sweeps at attach,:255-262sweeps again the first time an unheardstream_refarrives,:289-315is the sweep). Announcing one of several leaves the subscriber holding samples it has no schema for, and holding an announcement that looks like the answer. - Announcements and samples interleave. A stream that starts publishing an hour
into a session announces itself an hour in
(
proto/ranvier/message/v1/transport.proto:14-16). - A publisher MUST NOT send two different
StreamInfovalues for onestream_refon one connection. AStreamInfois fixed for the life of the stream it describes — every field in it is constant by construction, and the announcement is built once when the stream is opened (crates/runtime/node/src/lib.rs:769-779). The publisher tracks what it has already announced per connection and does not repeat it (crates/runtime/node/src/transport.rs:234,:300-313). - A subscriber MUST accept a sample whose
stream_refit holds no announcement for, and MUST NOT attribute it to another stream or guess a schema from a stream sharing the name (proto/ranvier/message/v1/transport.proto:75-81). This is a real hole rather than a theoretical one: the publisher's registry holds streams weakly, so a stream withdrawn between publishing a sample and the announcement sweep leaves nothing to announce, and the sample is sent anyway because an uncounted loss is the worse outcome (crates/runtime/node/src/transport.rs:45-52). - A subscriber SHOULD accumulate announcements keyed by
stream_refrather than holding one per connection (crates/runtime/node/src/lib.rs:405,Runtime::adopt_remote_stream, which keys by the reference and says at:417-425why the name and node are not enough).
4.3 Sample
proto/ranvier/message/v1/sample.proto. Five fields.
| # | Name | Type | Meaning |
|---|---|---|---|
| 1 | monotonic_timestamp_ns | fixed64 | When the publishing node handed the observation to Ranvier |
| 2 | sequence | uint64 | Position in this stream's own count, from 1 |
| 3 | values | bytes | The measurement, shaped by the announced schema |
| 4 | source_timestamp_ns | fixed64 | When the instrument measured it, on the instrument's clock |
| 5 | stream_ref | uint32 | Which announced stream this came from |
- Every timestamp in this contract MUST be
fixed64, so a reader never has to ask which encoding a particular one uses (proto/ranvier/message/v1/sample.proto:43-44,:126-129). monotonic_timestamp_nsMUST be a reading of the publishing host's system-wide, sleep-inclusive monotonic clock, in nanoseconds (proto/ranvier/message/v1/sample.proto:35-36; the Rust reads it atcrates/runtime/node/src/lib.rs:937). It is not wall-clock time and not the instrument's own timestamp. Two such readings MUST NOT be compared unless the announcements agree on bothhost_idandboot_id(proto/ranvier/message/v1/stream.proto:253-267): a monotonic clock restarts at an arbitrary point on every boot, so subtracting across a reboot produces a plausible number that means nothing.sequenceMUST start at 1 and MUST count each stream independently (proto/ranvier/message/v1/sample.proto:55-70;crates/runtime/node/src/lib.rs:931-935). Zero is unrepresentable: proto3 omits a zero-valued scalar, so "sequence 0" and "no sequence" are identical bytes. Two streams published under one name MUST NOT share a counter.- A forwarder MUST NOT renumber or re-stamp a sample. The sequence number is
what makes a gap mean a lost sample, and a receiver that assigned its own would
destroy the one thing it is for
(
proto/ranvier/message/v1/transport.proto:86-89; the Rust forwards the sample unchanged atcrates/runtime/node/src/transport.rs:264-274, and the republishing path passes the sequence and both timestamps through atcrates/runtime/node/src/lib.rs:1022-1061). valuesis opaque. An implementation MUST NOT require it to parse, and MUST NOT parse it in the framework layer (proto/ranvier/message/v1/sample.proto:72-96). It may hold a nested protobuf message or a packed numeric block; which one is the schema's business.source_timestamp_nsMUST be passed through untouched: never rebased onto the host clock, never pre-corrected by the publisher (proto/ranvier/message/v1/sample.proto:108-112). Zero means absent, and absent is ordinary — an analysis stage or a stimulus program has no upstream clock.- If the announcing
StreamInfo.source_clock_domainis empty, samples from that stream MUST carrysource_timestamp_ns = 0. A timestamp on a clock nobody named is unmappable, so the Rust drops one given to such a stream rather than sending it (crates/runtime/node/src/lib.rs:909-914, andsource_timestamp_is_carriedat:869-871). stream_refMUST be ≥ 1 in any sample that names a stream; 0 means absent (proto/ranvier/message/v1/sample.proto:155-158). A receiver MUST NOT attribute a sample withstream_ref = 0to any stream: the Rust loss tracker counts it as arrived and otherwise leaves it alone, because guessing produces exactly the false gaps the tracker exists to avoid (crates/runtime/node/src/gap.rs:172-178).stream_refis scoped to the runtime that announced it. A receiver MUST NOT assume references are unique across publishers: each runtime allocates from 1, so two publishers both have a stream 1 (proto/ranvier/message/v1/stream.proto:225-227;crates/runtime/node/src/lib.rs:199and:760-767). A subscriber tracking sequence numbers MUST key on the pair (which connection, which reference) (crates/runtime/node/src/gap.rs:40-43,:166).
4.4 StreamInfo
proto/ranvier/message/v1/stream.proto. Everything about a stream that does not
change from sample to sample.
| # | Name | Type |
|---|---|---|
| 1 | name | string |
| 2 | node | string |
| 3 | schema | SchemaInfo |
| 4 | origin | Origin enum |
| 5 | source_clock_domain | string |
| 6 | host_id | string |
| 7 | boot_id | string |
| 8 | operator | Identity |
| 9 | authorisation | Authorisation |
| 10 | stream_ref | uint32 |
| 11 | production | Production |
grep -c 'Production production = 11;' proto/ranvier/message/v1/stream.proto
Field 10 is out of sequence because it was added after fields 1-9 existed
(proto/ranvier/message/v1/stream.proto:218-232). Field numbers are permanent
from the first recording anyone intends to keep, and names are not
(proto/ranvier/message/v1/stream.proto:15-16). A decoder MUST decode by
number.
nameMUST be non-empty. It is the only address in the system, and the runtime refuses a stream without one (crates/runtime/node/src/announce.rs:292-297).stream_refMUST be ≥ 1, for the same reason it must be in a sample (proto/ranvier/message/v1/stream.proto:229-231).SchemaInfo.schema_idis the message's fully-qualified protobuf name, such asranvier.example.v1.Reading. It is identity, not interpretation (proto/ranvier/message/v1/stream.proto:115-121).SchemaInfo.descriptoris a serializedFileDescriptorSetdescribing that message and everything it imports. It is what lets a consumer that was never compiled against the schema read the values. A framework implementation MUST NOT parse it — carry the bytes to whoever wants them, exactly as with a sample's values (proto/ranvier/message/v1/stream.proto:122-137). A producer SHOULD announce the descriptor for the one message it carries rather than a whole descriptor set, so the announcement describes what the stream actually carries.origin(field 4) says what the data is — captured, computed, or produced for display. It changes no behaviour on its own, and an implementation MUST NOT derive a loss policy from it (proto/ranvier/message/v1/stream.proto:24-28). The wire values areORIGIN_UNSPECIFIED = 0,ORIGIN_RAW = 1,ORIGIN_DERIVED = 2andORIGIN_EPHEMERAL = 3(proto/ranvier/message/v1/stream.proto:33-48).
grep -c '^ ORIGIN_' proto/ranvier/message/v1/stream.proto
operatorrecords who the application said was operating, andIdentity.asserted_byhow it knew. An implementation MUST NOT present either as verified: Ranvier records the identity it was given and does not authenticate it (proto/ranvier/message/v1/stream.proto:50-79).Authorisation.valid_from_unix_nsandvalid_until_unix_nsare wall clock — nanoseconds since the Unix epoch — and not the host's monotonic clock (proto/ranvier/message/v1/stream.proto:101-110). This is the one place in this contract where afixed64timestamp is not on the monotonic clock, and a reader that treats it as monotonic gets a date fifty years out.Production.settingsis a repeatedSettingpair rather than amap, and a producer SHOULD write the settings in a stable order. Protobuf leaves a map's entry order unspecified, so the same settings serialize to different bytes on two runs, which makes a content hash over an announcement unstable (proto/ranvier/message/v1/stream.proto:197-204).
4.5 A frame, byte by byte
Frame { sample: Sample { monotonic_timestamp_ns: 1, sequence: 1, values: "A", source_timestamp_ns: 0, stream_ref: 1 } }, as the Rust encoder puts it on the
wire:
00 00 00 12 12 10 09 01 00 00 00 00 00 00 00 10 01 1a 01 41 28 01
Twenty-two bytes, read left to right:
| Bytes | Meaning |
|---|---|
00 00 00 12 | frame length: 18 bytes follow. Big-endian |
12 | Frame field 2 (sample), wire type 2 |
10 | that submessage is 16 bytes long |
09 | Sample field 1 (monotonic_timestamp_ns), wire type 1 |
01 00 00 00 00 00 00 00 | fixed64 value 1 — little-endian, per protobuf |
10 01 | field 2 (sequence), varint, value 1 |
1a 01 41 | field 3 (values), 1 byte, A |
28 01 | field 5 (stream_ref), varint, value 1 |
source_timestamp_ns is absent: proto3 writes nothing for a scalar holding its
default, which is why absence costs no bytes.
The Request that opened this connection, for gaze:
00 00 00 06 0a 04 67 61 7a 65
— six payload bytes: field 1, wire type 2, length 4, gaze.
And a Frame with no payload variant set, which is what an implementation emits
if it forgets to set the oneof:
00 00 00 00
A four-byte prefix and nothing else. It is a well-formed frame carrying a
well-formed Frame message, and the Rust subscriber skips it silently
(crates/runtime/node/src/transport.rs:497).
4.6 Field order
A writer MAY emit a message's fields in any order the protobuf encoding
permits, and a reader MUST accept any order. Protobuf serialisation is not
canonical, which is why the conformance corpus checks encoding by round trip
rather than by byte equality — see conformance.md.
The Rust writes fields in ascending field-number order, which is prost's
behaviour and not a requirement. An implementation that writes stream_ref first
is not wrong.
4.7 Ending a connection
Either end MAY close at any time. A publisher that drops its listening socket
stops accepting and leaves open connections to finish, because cutting a
publisher off mid-sample would create a loss nobody asked for
(crates/runtime/node/src/transport.rs:84-106).
A subscriber that reads end-of-stream MUST treat it as the connection ending
rather than as an error (crates/runtime/node/src/transport.rs:460).
5. The control plane
proto/ranvier/control/v1/control.proto, package ranvier.control.v1. It uses
the framing in §1 unchanged — four bytes big-endian, then that many
bytes, with the same 16 MiB ceiling — reused rather than re-specified.
grep -c 'ranvier_message::framing' crates/runtime/session/src/plane.rs
It is a separate protobuf package from ranvier.message.v1, so that no
control message appears inside the descriptor a stream announces, and so that
field-number permanence — which binds at the first recording anyone intends to
keep — does not bind on messages that never enter a recording
(proto/ranvier/control/v1/control.proto:18-25).
What crosses this connection, and what each message means, is specified in
control.md. This section covers only what belongs to the wire:
who dials, the message order, and the bytes.
5.1 Who dials
The node dials and the runtime listens — the opposite of the data plane. See §3.2 for the listener and the environment variables that carry its address.
5.2 The conversation
node → runtime ToRuntime { register } first frame, always
runtime → node ToNode { bind } or
runtime → node ToNode { refuse } then closes
node → runtime ToRuntime { listening } when its publishers are up
node → runtime ToRuntime { alive } on the period
runtime → node ToNode { bind | transport | stop } at any time
node → runtime ToRuntime { stopping } then exits
- A node MUST send
ToRuntime { register }as the first frame on the connection (crates/runtime/session/src/plane.rs:657-660;crates/runtime/session/src/control.rs:841-854). A runtime that receives any other message first MUST close the connection (crates/runtime/session/src/plane.rs:703-708). - A node MUST NOT send a second
Registeron one connection. The Rust runtime closes the connection if it sees one (crates/runtime/session/src/plane.rs:684-689). Register.protocolMUST be1. A runtime that speaks a different version MUST refuse withREFUSE_REASON_PROTOCOL(crates/runtime/session/src/plane.rs:72and:774-784). This is the static-versus-runtime split: a disagreement at match time is fatal, and a liveness miss forty minutes later is an observation (proto/ranvier/control/v1/control.proto:228-232).
grep -c 'pub const PROTOCOL: u32 = 1;' crates/runtime/session/src/plane.rs
- A runtime that refuses MUST send
ToNode { refuse }and then close (crates/runtime/session/src/plane.rs:692-700). A node that is refused SHOULD report it and exit non-zero; there is nothing for it to do in a session it is not part of (crates/runtime/session/src/control.rs:461-466). The Rust node does not redial after a refusal (crates/runtime/session/src/control.rs:970-973). - The runtime refuses for exactly five reasons, and each has an enum value:
protocol, token, unknown node, port drift, already registered
(
crates/runtime/session/src/plane.rs:774-839;proto/ranvier/control/v1/control.proto:211-225). - A node MUST treat each
Bindas its complete binding set, replacing whatever it held; there is no unbind verb, and a node holding a stale binding cannot exist (proto/ranvier/control/v1/control.proto:194-199;crates/runtime/session/src/control.rs:941-947). A node SHOULD treat an unchanged entry as a no-op and not tear down a working subscription. - A
BindingInfowith an emptyfrommeans bound to this stream, address not yet known. The node MUST bring the port up and MUST NOT dial (proto/ranvier/control/v1/control.proto:179-184). A freshBindcarries the address when the publisher'sListeningarrives (crates/runtime/session/src/plane.rs:992-1001). - A node MUST NOT wait for a second
Bindat the five-second timeout.proto/ranvier/control/v1/control.proto:180-184describes the same binding being issued again withfromstill empty at the timeout. Nothing issues it. What the expiry produces is aNotComingevent (crates/runtime/session/src/plane.rs:1193-1195,:1216-1220) — what expires is the runtime's claim to be waiting, not the binding. - A node MUST send
Aliveat least as often asBind.heartbeat_period_ms, from the moment thatBindarrives. The runtime declares a node gone after three consecutive periods of silence (crates/runtime/session/src/plane.rs:92,:1170-1191), and the node reads the period on every pass rather than once, so a shorter period takes effect immediately (crates/runtime/session/src/control.rs:886-893,:923-934). Aheartbeat_period_msof 0 leaves the node on its default of two seconds (crates/runtime/session/src/control.rs:948-950;crates/runtime/session/src/plane.rs:83).
grep -n 'const MISSES' crates/runtime/session/src/plane.rs
Alivecarries a sequence number and nothing else — no rates, no counts, no queue depths. Liveness is not health, and a message carrying both would report a node unhealthy at the moment it was working hardest (proto/ranvier/control/v1/control.proto:121-130). A node MUST send the field. A peer MUST NOT rely on another implementation acting on it: the Rust runtime counts beats and discards the message body (crates/runtime/session/src/plane.rs:971-976), so the redial detection the contract describes atproto/ranvier/control/v1/control.proto:127-129is not performed by any implementation here.- A node that receives
StopSHOULD stop producing, drain, seal what it has open, sendToRuntime { stopping }, and exit withinStop.deadline_ms. At the deadline the runtime kills what is left and records that it killed rather than stopped (proto/ranvier/control/v1/control.proto:252-265;crates/runtime/session/src/control.rs:703-723). - A node that did not set
Register.accepts_transportMUST ignore aTransportmessage and SHOULD report having received one (proto/ranvier/control/v1/control.proto:98-100;crates/runtime/session/src/control.rs:984-993). - A node whose connection closes for any reason other than a refusal MAY
redial. The Rust redials on the heartbeat period, without backoff, forever, and
re-sends its last
Listeningon the new connection because it never stopped publishing (crates/runtime/session/src/control.rs:811-828,:864-879).
5.3 A control frame, byte by byte
From the conformance corpus, case alive-at-the-default —
ToRuntime { alive: Alive { sequence: 0 } }:
00 00 00 02 1a 00
| Bytes | Meaning |
|---|---|
00 00 00 02 | frame length: 2 bytes follow |
1a | ToRuntime field 3 (alive), wire type 2 |
00 | that submessage is 0 bytes long |
An implementation that cannot read an empty submessage fails here rather than in
the field (conformance/write-encoding.py:259). With sequence: 7 the same
frame is 00 00 00 04 1a 02 08 07.
6. Error handling
A specification that omits the error cases produces implementations that disagree exactly when something goes wrong. This section states every case the implementation treats as a protocol violation, and what it does.
6.1 Framing errors
| Case | What the Rust does | Where |
|---|---|---|
| Fewer than 4 bytes buffered | Waits. Not an error | crates/message/src/framing.rs:123-125 |
Prefix claims > MAX_PAYLOAD | Returns TooLarge { claimed } without allocating | crates/message/src/framing.rs:130-132 |
| Whole frame not yet arrived | Waits. Not an error | crates/message/src/framing.rs:133-135 |
Payload handed to the writer > MAX_PAYLOAD | Returns PayloadTooLarge { size }, writes nothing | crates/message/src/framing.rs:86-90, :360 |
A reader MUST distinguish not yet from wrong. Ok(None) on a socket is
the ordinary case and is not a failure
(crates/message/src/framing.rs:120-121); treating a short read as an error
produces a connection that drops under load and works on loopback.
6.2 After a framing error
Once a prefix cannot be believed, the byte stream is no longer aligned and every byte after it is suspect. An implementation MUST NOT attempt to resynchronise by scanning for a plausible next prefix; there is no framing marker to scan for.
- Data plane, subscriber: closes the connection
(
crates/runtime/node/src/transport.rs:501-505— closing is more honest than guessing where the next one starts). - Data plane, publisher, while reading the
Request: closes the connection, silently (crates/runtime/node/src/transport.rs:357-362). - Control plane, both ends: the framing error is turned into an
InvalidDataI/O error (crates/runtime/session/src/plane.rs:1265-1282), which the runtime's loop treats as the connection ending (:717) and the node's loop treats as a lost connection to redial (crates/runtime/session/src/control.rs:913-920).
6.3 A frame that is not the message it should be
Here the three readers in this repository diverge, and the divergence is deliberate on the control plane and undocumented on the data plane.
| Reader | Payload that fails to decode | Where |
|---|---|---|
| Data plane subscriber | Skips the frame, keeps the connection | crates/runtime/node/src/transport.rs:478-480 |
Data plane publisher (the Request) | Closes the connection | crates/runtime/node/src/transport.rs:358 |
| Control plane runtime | Closes the connection — a node speaking something else | crates/runtime/session/src/plane.rs:676-682 |
| Control plane node | Ignores the frame, keeps the connection | crates/runtime/session/src/control.rs:906 |
An implementation MUST NOT treat a payload that fails to decode as a framing error: the frame boundary was correct, so the stream is still aligned and the next frame is readable. Whether to skip or to close is per channel, and the four rows above are what the Rust does.
6.4 A well-formed message with nothing in it
A Frame with no payload variant set, and a ToRuntime or ToNode with no
message variant set, are legal protobuf. A receiver MUST NOT treat one as
an error.
- The data plane subscriber skips it (
crates/runtime/node/src/transport.rs:497). - The control plane runtime ignores it
(
crates/runtime/session/src/plane.rs:711). The runtime relies on this: when a control message somehow exceeds the frame ceiling, the sender emits an empty frame instead, which the far end decodes as a message that does nothing rather than raising a panic in a session (crates/runtime/session/src/plane.rs:1226-1241).
6.5 Timeouts and silence
There is no heartbeat on the data plane. What exists:
- Both ends of a data-plane connection set a 30-second socket timeout
(
crates/runtime/node/src/transport.rs:77,:187,:218-219). - A read timeout at the subscriber is a quiet publisher, not a broken one.
The subscriber continues and does not close
(
crates/runtime/node/src/transport.rs:462-464). A subscriber MUST NOT close a connection because no sample arrived. - A publisher whose socket buffer is full MUST NOT treat that as the
subscriber being broken. A subscriber that cannot keep up is not a broken
subscriber: the Rust waits, resetting its patience on every byte of progress,
and gives up only when the peer has accepted nothing at all for the whole 30
seconds (
crates/runtime/node/src/transport.rs:379-436). Treating a full buffer as fatal disconnected subscribers silently, and the subscriber had no way to know (:383-393). - The publisher never reads its socket after the
Request(crates/runtime/node/src/transport.rs:221), so it cannot detect a subscriber that has gone silent without closing. It notices when a write fails.
On the control plane, the runtime declares a node gone after MISSES ×
heartbeat_period_ms of silence — three periods
(crates/runtime/session/src/plane.rs:92, :1170-1191). Being declared gone
MUST NOT tear down anything on the data plane; it is a report
(crates/runtime/session/src/plane.rs:127-133).
7. Versioning
7.1 What version means where
The data plane has no version field. There is no negotiation and no handshake
beyond the Request. The version lives in the protobuf package name —
ranvier.message.v1 — because that is how a breaking change is made at all:
field numbers are permanent within a message, so a genuinely incompatible
contract is published as v2 alongside v1, and both can exist in one binary
while old recordings stay readable (crates/message/src/lib.rs:17-24).
An implementation therefore MUST NOT expect to be told which version its peer speaks on the data plane. Two peers speaking different major versions of the contract discover it as fields that decode into the wrong shape, or not at all.
The control plane has one version number, checked once. Register.protocol
is 1 (crates/runtime/session/src/plane.rs:72), and a mismatch is refused at the
moment the node registers rather than misread forty minutes in (:774-784).
7.2 Unknown fields
A decoder MUST accept a message containing field numbers it does not know and
MUST decode the fields it does know. A Sample with an extra field 9
appended decodes with sequence, stream_ref and values intact.
This is what makes additive evolution work, and the drift it already covered is
recorded at crates/message/README.md:89-98: a decoder generated from an older
copy of stream.proto skipped StreamInfo.production (field 11) without error.
What that decoder could not do is tell anybody the field was there.
A decoder MAY discard unknown fields rather than preserving them. The Rust
discards them — prost has no unknown-field set — so a program that decodes a
message and re-encodes it drops any field it did not know. Nothing in this
repository re-encodes a Frame, so nothing is currently lost this way; a relay
would be affected, and a relay is required to forward frames without opening
them (proto/ranvier/message/v1/transport.proto:51-53), which sidesteps the
question. No relay exists — see §9.
7.3 Unknown oneof variants
A decoder MUST NOT fail on a Frame, ToRuntime or ToNode carrying a
variant field number it does not know. It decodes as a message with no variant
set, and §6.4 applies. A known
variant appearing in the same payload as an unknown one still arrives.
7.4 Unknown enum values
Every enum in ranvier.control.v1 declares an _UNSPECIFIED = 0, and an
unrecognised value reads as unspecified — an unknown value falls to the weakest
claim rather than to the nearest one
(proto/ranvier/control/v1/control.proto:27-29).
A decoder MUST NOT reject a message carrying an undeclared enum value. Where
the Rust converts an enum for its own use it applies the rule above:
RefuseReason falls to Unspecified
(crates/runtime/session/src/control.rs:959-960), and an unrecognised
TransportAction produces no action at all rather than the nearest one
(:994-999).
The value itself survives decoding. These fields are open enums carried as integers, so a value of 99 decodes as 99 and is available to an implementation that wants to log it. An implementation MAY surface the raw value; it MUST NOT act on an undeclared one as though it were a declared neighbour.
7.5 Truncated and corrupt payloads
A payload that ends mid-field is a decode failure, not a framing failure, and §6.3 applies.
An empty payload needs care: it is a valid message with every field at its
default, so a receiver that reads it as a sample sees sequence = 0 and
stream_ref = 0. Both are the "absent" values this contract reserves, which is
why sequence counts from 1 and stream_ref starts at 1.
8. Not yet specified
Each of these is behaviour the implementation happens to have rather than behaviour anything decided, or a question nothing in this repository answers. An implementation is free here. Closing one of these is a change to this document.
On the wire
- Two payload variants in one message. A
Framewhose payload carries both anannounceand asampleis legal protobuf, andprostkeeps whichever came last. A sender MUST NOT emit one. A receiver's behaviour is not specified. - What a receiver does with an unknown
Framevariant beyond not failing. The Rust subscriber counts it as an unreadable frame and keeps the connection (crates/runtime/node/src/transport.rs:606-608). Nothing requires that. This entry recorded a divergence — the Rust skipped it silently and the hand-written Python client counted it — and the divergence is gone twice over: 0048 made the Rust count it, and the Python client was deleted on 2026-08-23. It stays unspecified because one implementation agreeing with itself decides nothing. - Whether unknown fields survive a decode-and-re-encode. See §7.2. Open until something re-encodes.
- Any bound on
SchemaInfo.descriptor. Nothing limits its size below the 16 MiB frame ceiling, and nothing states what a receiver should do with an announcement that approaches it.
Liveness and reconnection
- Data-plane liveness. There is no heartbeat, no keepalive frame, and no
rule about how long a subscriber may wait before concluding a publisher has
gone. The Rust waits indefinitely
(
crates/runtime/node/src/transport.rs:462-464). The 30-second socket timeout in §6.5 is a socket option, not a protocol deadline: nothing decided its value, which end enforces it, or what the other end observes. - Whether a subscriber may reconnect and what it should expect. The Rust
subscriber does not reconnect on its own; the caller opens a new connection,
which re-runs the whole handshake and receives every announcement again.
Sequence numbers continue from where the publisher's counters are, so a
reconnecting subscriber sees its first sample at whatever number the stream
has reached — which the loss tracker treats as having missed nothing
(
crates/runtime/node/src/gap.rs:180-193). - Ordering between streams multiplexed on one connection. Samples from one
stream arrive in sequence order because numbering and delivery happen under
one lock (
crates/runtime/node/src/lib.rs:220-232). Nothing states an ordering between two streams sharing a connection, and TCP's in-order delivery means a stalled retransmission on one delays the others — a cost the contract records and does not resolve (proto/ranvier/message/v1/sample.proto:191-198).
Announcement consistency
- A stream that names a
source_clock_domainand publishes no source timestamps. The reverse case is enforced (§4.3). This one is described as reported rather than refused (crates/runtime/node/src/announce.rs:287-291), and nothing reports it:SpecError::ClockDomainWithoutTimestampsis declared and given aDisplayarm in that one file, and constructed nowhere.
grep -rl 'ClockDomainWithoutTimestamps' crates --include=*.rs
Beyond one machine
- Transport security and authentication. Both planes are plain TCP. There is
no TLS, no authentication on the data plane, and the control plane's token is
a session secret passed through the environment rather than a credential
(
crates/runtime/session/src/plane.rs:642-653). Identity in aStreamInfois recorded, never verified. This blocks any connection crossing a device boundary — see §3.4. - The cross-device default port and range. Whether the range is per publisher or per host is open too.
- Discovery.
_ranvier._tcpover mDNS and unicast DNS-SD is settled, but what the SRV record resolves to, what a runtime answers with, and how a subscriber then asks a runtime which streams exist are not. - Transports other than TCP. Nothing else is implemented, and nothing states how this framing would map onto a datagram transport, where a length prefix is redundant.
9. Relay
A relay is not built. Upstream::Via parses, validates, and is refused at
bind time with a message saying so. When one is built:
- A relay MUST forward frames transparently, without decoding a sample or re-stamping a sequence number.
- Two properties follow: a relay needs no codec, no schema knowledge and no clock; and a frame it drops under pressure shows up as a sequence gap the subscriber already counts.
- A relay is a supported fallback, not the default.
The one piece that must be right before a relay is built is the indirect path
in BindingInfo — the binding step has to be able to express one, and adding it
afterwards is the expensive change, because BindingInfo crosses the control
plane every implementation speaks. It has no relay field today.
10. Re-deriving everything here
# The two constants, and the tests that pin them to literals.
grep -n "pub const PREFIX\|pub const MAX_PAYLOAD" crates/message/src/framing.rs
cargo test -p ranvier-message --lib framing
# The contract itself.
ls proto/ranvier/message/v1 proto/ranvier/control/v1
# The control-plane corpus, and the runner that reads it.
python -c "import json;d=json.load(open('conformance/control/v1/encoding.json',encoding='utf-8'));print(d['framing']);[print(c['name'],c['frame']) for c in d['cases']]"
cargo test -p ranvier-session --test conformance
# Every citation in this document, resolved; every check block above, run.
python docs/docs.tools/check-spec.py
The worked examples in §4.5 were produced by encoding
the messages with the committed ranvier-message crate and printing the bytes.
To reproduce them, build a small program against crates/message that calls
prost::Message::encode_to_vec on the values named there and passes the result
to ranvier_message::framing::write_frame.