The Ranvier control plane, version 1
This is the normative specification of the conversation between a Ranvier runtime and a node it spawned. Implement it and your node — in C#, Python, JavaScript, or anything that can open a TCP socket and decode protobuf — joins a session, receives its bindings, reports where it landed, and shuts down cleanly.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.
0. How to read this, and how to check it
This document is authoritative. Where it and any other document in this
repository disagree about the control plane, 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 below carries a file and line — for example
crates/runtime/session/src/control.rs:467. 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, so
grep -n "fn join" crates/runtime/session/src/control.rs re-derives the reference
when a line has drifted. A citation that has drifted is a defect in this
document — fix the number here.
Where this document says MUST, the rule is enforced by code you can point at, and the citation is that code. Where the implementation does something that nothing requires — a choice an equally correct implementation could make differently — this document says MAY and records what the Rust does. §12 collects everything nothing has decided.
The node's end of this is reachable from JavaScript and not yet from Python.
Until 2026-08-23 it was reachable from neither: the two hand-written clients
spoke ranvier.message.v1 alone and carried no control message, no
environment-variable read and no registration. They are deleted, and the
bindings that replaced them reach the control plane through the C ABI rather
than by speaking this protocol themselves — so the citations in this document
stay Rust, and what a binding does is call it.
{ grep -rl 'ranvier_join' bindings/javascript/src | wc -l
grep -rl 'ranvier_join' bindings/python/src | wc -l
} | tr -d ' ' | tr '\n' ' '
bindings/javascript/src/control.rs binds ranvier_join and the accessors
around it — a node written in Node can join a session, read its bindings, report
where it landed and read a refusal in full. bindings/python binds none of it;
that deferral is recorded in docs/reference/deferred.md. Neither implements
the runtime's end, which accepts registrations and issues binds, and the ABI
does not export it (0047 §7).
So this specification is still what a from-scratch control-plane implementation will be built against — and it now also describes something two applications reach rather than reimplement.
The framing these messages travel in is specified in wire.md §1, and
what a node owes beyond this connection in node.md.
1. Transport and framing
A node MUST speak this protocol over a TCP connection it opens to the address in
RANVIER_CONTROL (§2). The runtime binds 127.0.0.1:0 and accepts on it
(crates/runtime/session/src/plane.rs:429, Listener::open_with). Nothing else
carries control messages — no UDP, no shared memory, no second socket.
Every message on this connection MUST be framed as a four-byte big-endian
unsigned length, followed by exactly that many bytes of protobuf payload
(crates/message/src/framing.rs:39-42, MAX_PAYLOAD and PREFIX;
crates/message/src/framing.rs:85-95, write_frame). This is the same framing the
data plane uses.
- A payload MUST NOT exceed 16,777,216 bytes (16 MiB)
(
crates/message/src/framing.rs:39). A reader that sees a prefix claiming more MUST treat the connection as failed rather than allocating on the strength of the number (crates/message/src/framing.rs:130-132,FramingError::TooLarge). Exactly 16,777,216 is legal; one more is not (crates/message/src/framing.rs:331-347, the boundary test). - A payload of zero bytes is a legal frame
(
crates/message/src/framing.rs:302-310). proto3 encodes a message whose fields all hold their defaults as zero bytes, so this is ordinary rather than exceptional. - A reader MUST tolerate a frame arriving split across any number of reads, and
MUST tolerate several frames arriving in one read
(
crates/message/src/framing.rs:248-281). On loopback with small messages the bytes almost always arrive whole; an implementation that assumes it will pass every local test and fail elsewhere.
The payload of every frame a node sends MUST be a ranvier.control.v1.ToRuntime.
The payload of every frame a node receives MUST be decoded as a
ranvier.control.v1.ToNode. Both are oneof envelopes, so a frame carries
exactly one message (proto/ranvier/control/v1/control.proto:156 and :291).
A node SHOULD set TCP_NODELAY on the connection
(crates/runtime/session/src/control.rs:839, dial). The runtime does the same on
its side (crates/runtime/session/src/plane.rs:663, serve). Nothing in the
protocol depends on it.
1.1 The messages
The contract is proto/ranvier/control/v1/control.proto, package
ranvier.control.v1. Eight messages, four each way:
| Node → runtime | Runtime → node |
|---|---|
Register | Bind |
Listening | Refuse |
Alive | Stop |
Stopping | Transport |
Every enum in this contract declares _UNSPECIFIED = 0. An implementation
receiving an enum value it does not recognise MUST read it as the unspecified
value — the weakest claim, not the nearest one
(proto/ranvier/control/v1/control.proto:27-29, and
crates/runtime/session/src/control.rs:959-961 for RefuseReason,
crates/runtime/session/src/control.rs:996-1000 for TransportAction).
Field numbers in this package are not yet permanent. The .proto states that
they are free to move until a second implementation ships against them
(proto/ranvier/control/v1/control.proto:22-25). Once you ship, they are.
1.2 Byte-level conformance
conformance/control/v1/encoding.json pins twelve framed messages as hexadecimal
bytes beside the values they decode to. Your implementation:
- MUST decode each
frameto the statedvalue. That direction is byte-golden. - MUST round-trip each
value: encode it, decode it again, and get the same value. It MUST NOT be asserted as byte equality — protobuf serialisation is not canonical and an implementation that orders fields differently is not wrong (conformance/control/v1/encoding.json:11-13,directions).
Each frame in the fixture includes its length prefix, so you can feed the bytes
to whatever reads your socket (conformance/control/v1/encoding.json:8). The
fixture's bytes were encoded by hand from the field numbers rather than by the
Rust protobuf library, so decoding them checks two independent encoders against
each other (conformance/write-encoding.py:6-11). The Rust runner is
crates/runtime/session/tests/conformance.rs; ship your own, and treat the fixture
rather than the runner as the contract
(crates/runtime/session/tests/conformance.rs:11-12).
Regenerate the fixture with python conformance/write-encoding.py.
2. The environment contract
The runtime sets four variables on every child it spawns
(crates/runtime/session/src/run.rs:361-364, spawn). Their names are pinned by a
test (crates/runtime/session/tests/sessions.rs:363-366).
| Variable | Format | Must a node read it? |
|---|---|---|
RANVIER_CONTROL | host:port — the runtime's control listener | MUST |
RANVIER_TOKEN | an opaque string | MUST, when RANVIER_CONTROL is set |
RANVIER_NODE_ID | the node's identity in the graph | SHOULD |
RANVIER_PARAMS | a JSON object of settings | MAY |
2.1 RANVIER_CONTROL
Constant: crates/runtime/session/src/launch.rs:57.
A node MUST read RANVIER_CONTROL before it does anything else, because its
value decides which of the two channels in §9 exists.
- The value MUST be parsed as
host:portand dialled with TCP. - An implementation MUST trim surrounding whitespace from the value, and MUST
treat a value that is empty after trimming as absent
(
crates/runtime/session/src/launch.rs:76-81,control_address). Set-to-empty and unset are the same state. - Absent is not an error. A node whose
RANVIER_CONTROLis absent is being run by hand, which is a supported way to run one. It MUST NOT fail, MUST NOT dial, and MUST take the standard-input channel instead (§9) (crates/runtime/session/src/control.rs:470-496, theelsebranch ofjoin). - A node whose dial fails MUST NOT treat that as fatal. It holds no bindings, publishes nothing, opens no instrument, and retries in the background (§4.5).
2.2 RANVIER_TOKEN
Constant: crates/runtime/session/src/launch.rs:63.
A node that dials MUST send this value verbatim in Register.token.
- An absent variable MUST be read as the empty string
(
crates/runtime/session/src/launch.rs:89-91,control_token). The empty string is a legal value to send, and the runtime refuses it withREFUSE_REASON_TOKENbecause it will not match — which is the right outcome for a node that dialled a session it is not part of. - An implementation MUST NOT print this value in a diagnostic. It is separate
from the address precisely so a node can log where it is dialling without
logging the secret (
crates/runtime/session/src/launch.rs:60-62;crates/runtime/session/src/plane.rs:474-477). The runtime's own refusal detail quotes neither token (crates/runtime/session/src/plane.rs:786-790, and the test atcrates/runtime/session/tests/plane.rs:813-817). - The token is not authentication. It is generated from the clock and the
standard library's random hasher state, and its stated purpose is catching a
node from a different session that dialled the wrong port
(
crates/runtime/session/src/plane.rs:628-653,token). A control plane reachable from another host needs authentication this does not provide.
2.3 RANVIER_NODE_ID
Constant: crates/runtime/session/src/launch.rs:69.
A node SHOULD send this value in Register.node_id. It is what the runtime
matches a registration against the graph with, so a node that sends anything else
is refused with REFUSE_REASON_UNKNOWN_NODE (§4.4).
- An absent or empty variable MUST NOT be fatal
(
crates/runtime/session/src/launch.rs:95-97,node_id— the empty string is filtered toNone). - When it is absent, an implementation MUST send some identity in
Register.node_idanyway; the field has no absent form. The Rust falls back to the program name its caller passed tojoin(crates/runtime/session/src/control.rs:501). Any fallback is permitted, and it will be refused as unknown by any runtime that did notexpectthat name. - A node MAY use this value in its own log lines. That is what tells two Muse
headbands apart on one console
(
crates/runtime/session/src/launch.rs:65-68).
2.4 RANVIER_PARAMS
Constant: crates/runtime/session/src/launch.rs:107.
The node's settings from the graph document, as a JSON object mapping setting
name to value. Values are strings, whole numbers, real numbers, or booleans
(crates/runtime/session/src/launch.rs:128-139, Param; serialised untagged, so
the JSON carries bare scalars and no type tag).
- Reading it is OPTIONAL. A node that declares no settings ignores it.
- An absent variable MUST be read as an empty settings map, not as an error
(
crates/runtime/session/src/launch.rs:294-299,settings). - A variable whose value is empty or whitespace MUST be read as an empty
settings map (
crates/runtime/session/src/launch.rs:311-313,decode_params). - A variable that is set to something that is not a JSON object of scalars is an
error. The Rust returns it to the caller
(
crates/runtime/session/src/launch.rs:314-316), and every node in this repository treats it as fatal. An implementation SHOULD report it and exit non-zero: the document has already been checked against the type's manifest before this variable is written, so a value that will not parse here is a bug in the runtime rather than in the document (crates/runtime/session/src/launch.rs:110-117). - A setting a node asks for and the document did not set MUST resolve to the
node's own fallback rather than to an error
(
crates/runtime/session/src/launch.rs:155-199).
The same variable, carrying the same JSON, is how a probe is told what the document configured (§10).
2.5 What else a spawned node is given
The runtime spawns children with standard input as a pipe, and standard
output and standard error inherited
(crates/runtime/session/src/run.rs:365, and the reasoning at
crates/runtime/session/src/run.rs:350-357). A node MAY print to standard output;
an operator watching a session start is reading those lines. See §9 for what a
node MUST NOT do with standard input.
No other variable is part of this contract. RANVIER_BINDINGS was removed —
bindings arrive on the connection and nowhere else
(crates/runtime/session/src/launch.rs:18-31). An implementation MUST NOT read
bindings from the environment.
3. The conversation
node → runtime Register the first frame, always
runtime → node Bind or Refuse, after which the runtime closes
node → runtime Listening once the node's publish bindings are up
node → runtime Alive on the period Bind gave it
runtime → node Bind | Transport | Stop at any time afterwards
node → runtime Stopping then the node exits
(proto/ranvier/control/v1/control.proto:3-11.)
4. Joining
4.1 Dial and speak first
A node MUST send Register as the first frame on the connection
(proto/ranvier/control/v1/control.proto:82-83;
crates/runtime/session/src/plane.rs:657-660, serve). One listener serves the
whole graph, so an accepted socket is anonymous until the node names itself, and
the runtime has nothing useful to say before it does.
A node MUST NOT send any other message before Register. The runtime closes the
connection on any other first frame
(crates/runtime/session/src/plane.rs:703-708).
A node MUST NOT send a second Register on one connection. The runtime closes
the connection on it (crates/runtime/session/src/plane.rs:684-689).
A node MUST register before it opens its instrument. Nothing in Register
needs hardware: the ports come from the node's own code and the type from its
manifest (crates/runtime/session/src/control.rs:294-301, the About header;
0036 §3). This is a rule and not an observation — it is what makes the runtime's
five-second hold (§6.3) a bound on process start rather than on device discovery.
4.2 What a node advertises
Register (proto/ranvier/control/v1/control.proto:84-110):
| Field | Requirement |
|---|---|
protocol (uint32) | MUST be 1 for this version (crates/runtime/session/src/plane.rs:72, PROTOCOL) |
token | MUST be RANVIER_TOKEN verbatim (§2.2) |
node_id | MUST be the node's identity in the graph (§2.3) |
node_type | SHOULD be the type its manifest names |
package_version | SHOULD be the package's own version |
pid (uint32) | SHOULD be the node's process identifier |
accepts_transport (bool) | MUST be true only if the node acts on Transport (§8) |
stop_deadline_ms (uint32) | MAY state how long the node asks for on Stop (§7.4) |
ports (repeated PortInfo) | MUST be what the node's own code declares (§4.3) |
The Rust sends std::process::id() for pid
(crates/runtime/session/src/control.rs:849) and converts a requested stop
deadline to milliseconds, saturating at u32::MAX
(crates/runtime/session/src/control.rs:506).
The runtime reads only five of these nine fields. node_id, protocol,
token, ports and pid are used
(crates/runtime/session/src/plane.rs:766, :774, :786, :828, :858).
node_type, package_version, accepts_transport and stop_deadline_ms are
received and ignored by this runtime. Send them correctly anyway — a future
runtime reading them is a change to the runtime and not to this contract.
stop_deadline_ms is a request that nothing currently grants. The .proto
describes it as a request the runtime bounds with its own floor and cap
(proto/ranvier/control/v1/control.proto:101-105). No line in the runtime reads
the field: the Stop frame is built from the caller's deadline alone
(crates/runtime/session/src/plane.rs:894-901), capped at thirty seconds
(crates/runtime/session/src/run.rs:499-505), with no floor applied
(crates/runtime/session/src/run.rs:495-497). A node MUST NOT rely on the
deadline it asked for. Read Stop.deadline_ms and obey that.
grep -c stop_deadline_ms crates/runtime/session/src/plane.rs
4.3 What a node says about its ports
One PortInfo per port the node's own code declares
(proto/ranvier/control/v1/control.proto:53-64):
name— the port's local name. MUST be the node's own word for it. It MUST NOT be a stream name.direction—PORT_DIRECTION_INorPORT_DIRECTION_OUT. MUST be set; a port advertised asPORT_DIRECTION_UNSPECIFIEDdisagrees with any manifest that declares a direction and is refused (§4.4).schema_identity— the fully-qualified protobuf message name, such asranvier.example.v1.Reading. An implementation MUST send the identity and MUST NOT send a descriptor here: two builds of one schema carry byte-different descriptors, the runtime's only use for the value is comparison, and the subscriber already receives the descriptor on the data plane (crates/runtime/session/src/control.rs:748-751,advertise;proto/ranvier/control/v1/control.proto:47-52).schema_identityMAY be empty, and empty means the port makes no claim. A recorder's inputs name none. Empty on either side is not a disagreement (crates/runtime/session/src/plane.rs:939-947,disagreements).required— whether the node can run without this port being bound. The Rust fills it fromPort::required(crates/runtime/session/src/control.rs:752). The runtime does not compare it (crates/runtime/session/src/plane.rs:914-960,disagreements, which compares name, direction and schema only).
A node that advertises no ports at all is legal and still receives a Bind
(conformance/control/v1/encoding.json:52, register-with-no-ports). This case
matters: a node whose bind set is empty must still learn its heartbeat period and
its session identity, and a runtime that skipped the Bind for it left the node
waiting out its whole join patience
(crates/runtime/session/src/plane.rs:314-321, the issued: Option<…> comment).
4.4 What the runtime may answer
The runtime MUST answer a Register with exactly one of Bind, Refuse, or —
in one case — Stop.
Bind — accepted, and here is the node's complete binding set. See §5.
Refuse — the node cannot take part. The runtime MUST close the connection
after sending it (crates/runtime/session/src/plane.rs:690-701;
proto/ranvier/control/v1/control.proto:227-232), and the test asserts nothing
follows (crates/runtime/session/tests/plane.rs:789-792). Five reasons, and they
are checked in this order:
RefuseReason | When | Where |
|---|---|---|
REFUSE_REASON_PROTOCOL | Register.protocol is not the runtime's | crates/runtime/session/src/plane.rs:774-784 |
REFUSE_REASON_TOKEN | Register.token does not match the session's | crates/runtime/session/src/plane.rs:786-795 |
REFUSE_REASON_UNKNOWN_NODE | no node of that node_id is part of this session | crates/runtime/session/src/plane.rs:799-810 |
REFUSE_REASON_ALREADY_REGISTERED | a node of that node_id is connected on another connection | crates/runtime/session/src/plane.rs:812-823 |
REFUSE_REASON_PORTS | the manifest and the binary disagree about ports | crates/runtime/session/src/plane.rs:828-840 |
The order is load-bearing for a node that gets two things wrong at once: a wrong protocol is reported as a protocol disagreement even when the token is also wrong. An implementation MUST NOT rely on any particular order; it MUST handle every reason.
REFUSE_REASON_UNSPECIFIED is declared and this runtime never sends it
(proto/ranvier/control/v1/control.proto:213). A node MUST still handle it, as
the value any unrecognised reason reads as
(crates/runtime/session/src/control.rs:959-961).
Refuse.disagreements carries one line per disagreement and is populated only
for REFUSE_REASON_PORTS (crates/runtime/session/src/plane.rs:839). Both
directions of drift are named — a port the manifest declares and the node does
not advertise, and a port the node advertises and the manifest does not declare —
along with a direction mismatch and a schema-identity mismatch
(crates/runtime/session/src/plane.rs:914-960, disagreements; asserted at
crates/runtime/session/tests/plane.rs:767-785).
Stop — the node registered into a session that has already been told to
end. The runtime sends Stop instead of Bind and does not refuse: a refusal
closes the connection and turns arriving a few milliseconds late into a session
failure (crates/runtime/session/src/plane.rs:861-877, accept). See §7.3.
4.5 On refusal, and on no answer
A node that is refused SHOULD report the refusal 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, join's error contract; the
worked example does exactly this at
crates/runtime/session/src/bin/pulse.rs:92-98 and :169). It MUST NOT redial
after a refusal — the runtime has closed the connection and there is nothing to
redial into (crates/runtime/session/src/control.rs:970-973).
A node whose registration is not answered MUST NOT block for ever. An
implementation MUST bound its wait for the first answer. The Rust waits five
seconds (crates/runtime/session/src/control.rs:276, JOIN_PATIENCE; the wait at
:544-555). Five seconds is what this implementation chose, not a value the
protocol fixes; another implementation MAY choose differently, and SHOULD choose
a bound rather than none, because the failure mode of a protocol is a hang.
On expiry, a node MUST proceed with no bindings rather than fail. That state is
exactly what 0036 §4 describes for a dial that failed at start: the node
publishes nothing, opens no instrument, and keeps retrying the connection in the
background (crates/runtime/session/src/control.rs:531-543, and the retry loop at
:816-828).
A Stop is also a first answer. An implementation waiting for its first
Bind MUST stop waiting when a Stop arrives instead, and MUST proceed to shut
down (crates/runtime/session/src/control.rs:546, the !joined.stop.asked()
term). This is not a refinement. The Rust's join patience and the runtime's stop
deadline are both five seconds, so a node that waited out its patience for a
Bind that was never coming was killed at the exact moment it gave up — having
been told to stop five seconds earlier
(crates/runtime/session/src/control.rs:535-543; the regression test is
crates/runtime/session/tests/plane.rs:1182-1219).
5. Bindings
Bind (proto/ranvier/control/v1/control.proto:200-208) carries the node's
complete current binding set, a heartbeat period, and the session identity.
5.1 Replace, never merge
Each Bind carries the node's complete current binding set. A node MUST
replace whatever it held with what arrives, and MUST NOT merge
(proto/ranvier/control/v1/control.proto:196-199;
crates/runtime/session/src/control.rs:941-956, take). There is no unbind verb
and none is needed.
- A node MUST treat an unchanged entry as a no-op. It MUST NOT tear down and
rebuild a working subscription because a
Bindrestated it (proto/ranvier/control/v1/control.proto:198-199). - A node MUST close any connection to a stream the new set no longer names. A
node holding a connection the runtime has forgotten is the stale binding the
replace rule exists to make impossible
(
crates/runtime/session/src/control.rs:1035-1037, andcrates/runtime/session/src/control.rs:1113,Wiring::follow).
The runtime MAY suppress a Bind whose content equals the last one it issued;
this one does (crates/runtime/session/src/plane.rs:1122-1124, issue). A node
MUST NOT depend on receiving a Bind at any particular cadence.
5.2 What a binding says
BindingInfo (proto/ranvier/control/v1/control.proto:171-192):
port— the port's local name, as the node advertised it.direction—PORT_DIRECTION_OUTmeans publish;PORT_DIRECTION_INmeans subscribe.stream— the stream name this port is bound to.from— where to dial, for an input.device_id— the instrument identity this binding is bound to.
An empty from means bound to this stream, address unknown. A node MUST
bring the port up subscribed-and-not-connected and MUST NOT dial. It is not an
error, and it MUST NOT be reported as one
(proto/ranvier/control/v1/control.proto:179-185;
crates/runtime/session/src/control.rs:762-780, instruct, which maps an empty
from to a local upstream that reaches nothing;
crates/runtime/session/src/control.rs:1084-1087, Wiring::follow, which does not
report it). A later Bind carries the address.
An empty device_id means identity is not part of this binding, and MUST
stay legal. A processor, a recorder and a stimulus program have no device
(proto/ranvier/control/v1/control.proto:187-191).
A subscriber does not match on device_id. The field travels to the node and
the node ignores it: the translation of a BindingInfo reads port,
direction, stream and from and never touches device_id
(crates/runtime/session/src/control.rs:762-780, instruct), nor does the layer
that opens connections (crates/runtime/session/src/control.rs:1094-1130,
Wiring::follow). A node dials whatever address it is given, whatever identity
travelled with it. The four uses of the field are all on the outbound half, where
a node reports what it published:
grep -c device_id crates/runtime/session/src/control.rs
The runtime half of identity handling is built — it holds the old address when
an identity changes and reports the change
(crates/runtime/session/src/plane.rs:1026-1039, asserted at
crates/runtime/session/tests/plane.rs:879-993). An implementation MUST NOT
depend on a peer refusing an address whose device_id does not match, because no
implementation does that today.
An implementation MUST NOT assume a binding's direction is set. This runtime
always sets it (crates/runtime/session/src/plane.rs:1080 and :1110). The Rust
node treats PORT_DIRECTION_OUT as publish and everything else, including
PORT_DIRECTION_UNSPECIFIED, as subscribe
(crates/runtime/session/src/control.rs:762-780). That is a departure from the
weakest-claim rule in §1.1, nothing requires it, and an implementation SHOULD
set direction explicitly on every binding rather than rely on it.
5.3 The heartbeat period and the session
heartbeat_period_ms— how often the node sendsAlive. A node MUST adopt it when it is greater than zero, and MUST keep its current period when it is zero (crates/runtime/session/src/control.rs:948-950). The period is a setting the runtime chooses and not a constant in the protocol (proto/ranvier/control/v1/control.proto:203-205).- Until a
Bindhas said otherwise, a node MUST use a default period. The Rust uses two seconds (crates/runtime/session/src/plane.rs:83,HEARTBEAT_PERIOD; applied atcrates/runtime/session/src/control.rs:509). session_id— the session this node is part of, for a report that spans processes. A node MAY record it. Nothing requires it to act on it.
5.4 Noticing that the set moved
An implementation MUST make a changed binding set observable to the node's own
code without polling the socket. A node whose input port came up with an empty
from learns the address from a later Bind, and something has to wake it.
The Rust exposes a generation counter, bumped only when the new set differs from
the old (crates/runtime/session/src/control.rs:943-953), read with
Joined::generation (:599) and waited on with Joined::wait_for_bind
(:611-628). Any equivalent mechanism satisfies this. What matters is the
property wait_for_bind documents: waiting MUST be on the runtime's answer and
MUST NOT be on another node's progress, or a cycle in the graph deadlocks
(crates/runtime/session/src/control.rs:604-610).
6. Reporting, and being watched
6.1 Listening
A node MUST send Listening after its publish bindings are up and its
listener is accepting, and not before. A runtime that acted on this before the
listener existed would hand a subscriber an address nothing answers at
(crates/runtime/session/src/bin/pulse.rs:202-222).
Listening (proto/ranvier/control/v1/control.proto:113-119):
address—host:portwhere this node's publishers listen. It MAY be empty, and empty means the node publishes nothing and has nothing to be dialled at. A node that publishes nothing SHOULD still sendListeningwith an empty address; the worked example does (crates/runtime/session/src/bin/pulse.rs:224).published— onePublished { stream, device_id }per stream the node now publishes.device_idis what the node actually opened, and MAY be empty.
The node is authoritative about the instrument. A probe's id is a request —
open this one. Published.device_id is the answer — this is what I opened.
When they differ the runtime reports the difference and MUST NOT refuse
(proto/ranvier/control/v1/control.proto:70-77;
crates/runtime/session/src/plane.rs:1019-1053, landed).
What the runtime does with a Listening, per stream
(crates/runtime/session/src/plane.rs:1026-1052):
| Case | Runtime |
|---|---|
| Stream not seen before | Records address and device_id |
Known stream, held device_id empty or reported device_id empty | Takes the new address; adopts the reported device_id if it had none |
Known stream, both device_ids set and different | Keeps the old address, reports an identity change, issues no fresh Bind |
After recording, the runtime re-issues bindings for every connected node, not
only the one that landed — that is what tells a subscriber where its publisher
went (crates/runtime/session/src/plane.rs:992-1001, heard).
A node MUST resend its most recent Listening on every redial. Its address did
not change while the runtime was away, and a runtime that came back needs it to
answer that node's subscribers without asking
(crates/runtime/session/src/control.rs:864-879, dial;
crates/runtime/session/src/control.rs:688-694, which remembers it).
6.2 Alive
A node MUST send Alive on the period Bind gave it
(proto/ranvier/control/v1/control.proto:121-130).
sequence(uint64) counts from 1, per connection. The Rust's counter is per-connection and pre-incremented, so the first beat on a connection carries 1 (crates/runtime/session/src/control.rs:896and:925-929).AliveMUST carry a sequence number and nothing else. No rates, no counts, no queue depths. Liveness is not health, and a message carrying both reports a node unhealthy at the moment it is working hardest (proto/ranvier/control/v1/control.proto:123-125).- The heartbeat MUST NOT be sent from a thread that takes any lock the node's
data path takes. The Rust sends it from the thread that owns the control
socket and does no other I/O
(
crates/runtime/session/src/control.rs:803-815,speak; the load test iscrates/runtime/session/tests/plane.rs:1007-1061). This is structural rather than a shorter critical section, and it is the requirement most likely to be missed by an implementation that hangs the heartbeat off its publish loop. - Any message from a node counts as liveness, not only
Alive(crates/runtime/session/src/plane.rs:963-969,heard). An implementation MUST still sendAliveon the period; do not rely on incidental traffic.
What the runtime does with a missed heartbeat. After three consecutive
periods of silence it records the node as gone and reports it
(crates/runtime/session/src/plane.rs:92, MISSES;
crates/runtime/session/src/plane.rs:1170-1191, tick). It sends the node
nothing, tears down nothing on the data plane, and does not close the
connection — asserted by writing to the socket after the report at
crates/runtime/session/tests/plane.rs:715-731. A node that later speaks again is
no longer gone (crates/runtime/session/src/plane.rs:967-968).
6.3 What the runtime does not send you
Two runtime-side deadlines produce no message at all. An implementation MUST NOT wait for either.
- The hold. Five seconds after a node registers, the runtime stops claiming
to be waiting for the publisher of
xand reports that the publisher is not coming (crates/runtime/session/src/plane.rs:113,HOLD;crates/runtime/session/src/plane.rs:1196-1222). No frame is sent. The binding was already issued with an emptyfrom, and the node was already on its own retry path. The clock starts when the subscriber registered, not when the session started. - Gone. As above: reported to the operator, never to the node.
7. Stopping
7.1 Stop
Stop (proto/ranvier/control/v1/control.proto:252-265) carries:
deadline_ms(uint32) — how long the node has.reason—STOP_REASON_SESSION_ENDINGorSTOP_REASON_PEER_FAILED. This runtime sendsSTOP_REASON_SESSION_ENDINGin both places it constructs aStop(crates/runtime/session/src/plane.rs:894-901,stopping), soSTOP_REASON_PEER_FAILEDis declared and unsent today. A node MUST handle every value, includingSTOP_REASON_UNSPECIFIED.
It is a message and not a signal, because there is no documented way to ask
an arbitrary child process to shut down cleanly on Windows
(proto/ranvier/control/v1/control.proto:254-258; the evidence is 0036 §9).
7.2 What a node MUST do on receipt
On Stop, a node MUST, in order
(proto/ranvier/control/v1/control.proto:257-259):
- Stop producing.
- Drain what it already holds.
- Seal whatever it has open. A recorder that does not reach this leaves a
file CA3 reads as never finished — the harm this whole message exists to
prevent (0037 Context;
crates/runtime/session/src/run.rs:527-529). - Reply
Stopping. - Exit.
All five MUST happen inside deadline_ms.
The Rust library does steps zero and nothing more: take records the deadline
and raises the node's stop flag
(crates/runtime/session/src/control.rs:975-983). Everything above is the
implementing node's work. A node MUST make the ask observable to its own code
without polling the socket, and MUST offer a way for code that is asleep to be
woken by it — the Rust exposes a non-blocking check, an unbounded wait, and a
bounded wait (crates/runtime/session/src/control.rs:120, :129, :157).
A node MUST record deadline_ms and SHOULD bound its drain by it rather than by
a number it chose itself (crates/runtime/session/src/control.rs:645-647,
Joined::deadline; used at crates/runtime/session/src/bin/pulse.rs:288).
Stopping (proto/ranvier/control/v1/control.proto:144-150):
reason—STOPPING_REASON_ASKEDif the node is leaving because it was asked,STOPPING_REASON_COMPLETEif its own work finished,STOPPING_REASON_FAULTif something went wrong. The Rust helper sendsASKEDwhen the stop flag is raised andCOMPLETEotherwise, and never sendsFAULT(crates/runtime/session/src/control.rs:717-721). An implementation MAY sendFAULT; nothing here does.detail— for a person reading a session report. MAY be empty; when it is, the runtime prints the reason name alone (crates/runtime/session/src/plane.rs:984-989).
Stopping is the half a signal cannot carry. A killed process leaves nothing
behind saying whether it finished what it was doing. Send it before the process
exits (crates/runtime/session/src/control.rs:708-712), and the session report can
distinguish a node that was stopped from one that was killed
(crates/runtime/session/tests/plane.rs:506-526).
7.3 What happens if a node does not stop
At the deadline the runtime kills whatever is still running and records that it
killed rather than stopped (crates/runtime/session/src/run.rs:643-716,
stop_within). The recorded outcome is one of three
(crates/runtime/session/src/run.rs:519-544, How):
| Outcome | Meaning |
|---|---|
Stopped | Asked, and gone inside the deadline |
Ignored | Asked, still running at the deadline, killed |
Unreachable | Killed without ever having been asked, because no channel reached it |
Ignored asserts less than it sounds like: a wedged node, a node that read the
Stop and carried on, and a node whose flush took too long are indistinguishable
from the runtime's side (crates/runtime/session/src/run.rs:526-531).
Two orderings matter to an implementation:
- Everything is asked before anything is waited for, so the session's
patience is the longest node's rather than the sum of every node's
(
crates/runtime/session/src/run.rs:663-671, and the reasoning at:639-642). A node that ignores the ask does not delay a node that took it. - A node whose
Registerarrives after the session was told to end is sent aStopin place of itsBindand is counted as asked (crates/runtime/session/src/plane.rs:871-877;crates/runtime/session/tests/plane.rs:1090-1148). Handle aStopas the first message on a connection. An implementation that only handlesStopafter a successfulBindwill hang here, be killed at the deadline, and be reported as a node that would not go.
7.4 The deadline, and asking for more
The runtime's default is five seconds (crates/runtime/session/src/run.rs:478,
STOP_DEADLINE), and any caller-stated deadline is capped at thirty seconds —
the same bound the runtime applies to a probe, so there is one answer to how
long will the runtime wait for a child rather than two
(crates/runtime/session/src/run.rs:499-505, stop_deadline).
A node MAY request a longer deadline in Register.stop_deadline_ms. This
runtime never reads that field
(grep -rn stop_deadline_ms crates/runtime/session/src/plane.rs returns nothing;
the runtime's Stop is built from the caller's deadline alone at
crates/runtime/session/src/plane.rs:894-901). Send it if your node has something
to seal, and do not depend on it being honoured — see §4.2.
No floor is applied to a stated deadline: a caller MAY state a deadline shorter
than five seconds, and tests do (crates/runtime/session/src/run.rs:495-497;
crates/runtime/session/tests/plane.rs:1308). A node MUST NOT assume it has five
seconds. Read deadline_ms.
7.5 Ending the connection
- A node that is going away MUST send
Stoppingbefore it closes the connection or exits. A connection that closes with noStoppingis reported as a node that left without saying so (crates/runtime/session/src/plane.rs:746-758,leave). - Closing the connection MUST NOT be taken by either end as a stop. The node
keeps publishing and redials (§9.2 and 0036 §4); the runtime forgets the
connection and keeps the node's bindings and its landed address
(
crates/runtime/session/src/plane.rs:741-745). - An implementation SHOULD shut down the socket in both directions when it
releases its handle to the connection, so the runtime learns at once rather
than at three missed heartbeats
(
crates/runtime/session/src/control.rs:436-448,Drop for Joined;crates/runtime/session/src/plane.rs:219-228,Event::Left).
8. Transport actions
Transport { action } (proto/ranvier/control/v1/control.proto:285-288) carries
TRANSPORT_ACTION_PAUSE or TRANSPORT_ACTION_RESUME.
- A node MUST set
Register.accepts_transportto true only if it acts on these. - A node that did not declare
accepts_transportand receives one anyway MUST NOT act on it, and SHOULD say so rather than going quiet. The Rust writes a line naming the node to standard error and ignores the action (crates/runtime/session/src/control.rs:984-993). - A node MUST read an unrecognised
actionvalue as no action at all, rather than as the nearest one (crates/runtime/session/src/control.rs:996-1000). An unrecognised value therefore produces nothing, not a pause. - A node that does act MUST make the actions available to its own code in
order and MUST drain them, so the same action is not applied twice
(
crates/runtime/session/src/control.rs:657-663,Joined::transport).
The runtime does not check accepts_transport before sending. It enqueues a
Transport for any connected node it is asked to
(crates/runtime/session/src/plane.rs:587-601). The check is the node's, and it is
the only one there is.
Transport exists only because of the two-channel rule in §9. A generic
Command { verb } was the obvious alternative and is a protocol hole: any node
could invent a verb and no contract would cover it
(proto/ranvier/control/v1/control.proto:279-284).
9. The two channels
0036 §10 makes the control connection and standard input mutually exclusive.
This is not a convention. It is one branch on one variable
(crates/runtime/session/src/control.rs:450-456 and :467-496, join).
| Channel | |
|---|---|
RANVIER_CONTROL set (and non-empty after trimming) | The connection. Bind, Transport and Stop arrive on it, and the standard-input reader is not started |
RANVIER_CONTROL absent or empty | Standard input, unchanged. The node is being run by hand |
9.1 Which applies, and what you must not do
- An implementation MUST decide the channel from
RANVIER_CONTROLalone. It MUST NOT decide from whether the dial succeeded. A node whose dial failed is still on the connection channel and still retries (crates/runtime/session/src/control.rs:470, where the presence of the variable is the whole test; andcrates/runtime/session/src/run.rs:654-662, which spells out that the dial's outcome never enters into it). - An implementation MUST NOT start a standard-input control reader when
RANVIER_CONTROLis set. - An implementation MUST NOT run both channels at once. The reason is
substantive rather than tidy: a recorder that a shell paused and a runtime
resumed has a transport state that is a lie to whichever asked last
(
crates/runtime/session/src/control.rs:13-15; 0036 §10).
If an implementation somehow sees both. The contract has no state in which
both are live, so there is no defined behaviour to fall back on — but the case is
reachable by a bug, and it is reachable in one specific way. The runtime writes
stop\n into the standard-input pipe of any spawned child it did not queue a
Stop for (crates/runtime/session/src/run.rs:667-671, and the constant at
:460). Every spawned child has RANVIER_CONTROL set, so no reader is listening
and those bytes sit unread in the pipe
(crates/runtime/session/src/plane.rs:549-555). An implementation that violated
§9.1 and read standard input anyway would therefore observe a stop with no
deadline attached, possibly for a node the runtime had not decided to stop on
the connection. The correct behaviour is not to be in that state: when
RANVIER_CONTROL is set, a node MUST ignore standard input as a control
channel. Nothing in the code detects the violation, so this is a rule you enforce
in your own implementation or not at all.
9.2 The standard-input channel
For a node run by hand. Three words, one per line
(crates/runtime/session/src/control.rs:1151-1191, by_hand; the shared
vocabulary is also at :240-264, read):
| Line | Behaviour |
|---|---|
stop | The node is asked to stop, and the reader returns. Nothing after it is read (crates/runtime/session/src/control.rs:1162-1165) |
pause / resume | Honoured, as Transport actions, only if the node declared accepts_transport (crates/runtime/session/src/control.rs:1166-1184) |
pause / resume, node did not declare | Recognised, refused, and said out loud on standard error (crates/runtime/session/src/control.rs:1167-1174) |
| an empty or whitespace-only line | Nothing (crates/runtime/session/src/control.rs:1185) |
| anything else | Reported on standard error and not acted on (crates/runtime/session/src/control.rs:1186-1188) |
Rules that apply to all of them:
- A line MUST be trimmed before it is matched (
crates/runtime/session/src/control.rs:1161). - End of input MUST NOT be read as a stop (
crates/runtime/session/src/control.rs:1159, the loop simply ends; asserted atcrates/runtime/session/tests/asking.rs:52-66). This is not hypothetical: a runtime that spawns device nodes with standard input ignored leaves their input at end of file the instant they begin. A node that read that as a stop would exit at launch and look like hardware that failed to start. - An unreadable line ends the reader and MUST NOT ask the node to stop
(
crates/runtime/session/src/control.rs:1160). - A complaint SHOULD name the node, so an operator reading a console with six
nodes in it knows which one answered
(
crates/runtime/session/tests/asking.rs:80-84). - A stop arriving on this channel carries no deadline. An implementation MUST
report the deadline as absent rather than inventing one
(
crates/runtime/session/src/control.rs:641-647).
A node's own code MUST NOT be able to tell which channel carried an ask. That is
the point of there being one stop type and one transport queue
(crates/runtime/session/src/control.rs:575-582 and :649-663).
10. The probe contract
A probe is a program a package ships beside its manifest that answers whether one of that package's instruments is present, and then exits. Decision 0033 settles the shape.
10.1 How it is invoked
The runtime resolves the manifest's probe beside the manifest's own directory,
trying the name as written and then with the platform's executable extension, and
falling back to the bare name on the search path
(crates/runtime/session/src/probe.rs:227 and :333-344, beside).
It runs:
<probe> <probe-args…> --json
--jsonMUST be accepted, and MUST come after anyprobe-argsfrom the manifest (crates/runtime/session/src/probe.rs:234-243). The arguments select the question — one binary can answer more than one, andav's probe enumerates capture devices by default and playback devices given--desktop-audio(crates/runtime/session/src/registry.rs:132-141).RANVIER_PARAMSMAY be set on the probe, carrying the same JSON in the same format a node receives (§2.4) (crates/runtime/session/src/probe.rs:249-251). A probe MUST work when it is absent; that is the ordinary case, because a scan needs no address. A probe for an instrument that can only be knocked on — one reached at an address an operator typed — SHOULD read it.- A manifest that declares
probe-argsorprobingwithout declaring aprobeis refused when the registry is loaded (crates/runtime/session/src/registry.rs:598-618).
10.2 What it MUST print
A probe MUST print exactly one JSON object on standard output
(crates/runtime/session/src/probe.rs:298-326, read; the input is trimmed before
parsing at :302).
Either:
{"found": [ {"id": "…", "label": "…", "serial": "…",
"firmware": "…", "address": "…", "detail": {}} ]}
or:
{"error": "no Bluetooth adapter"}
erroris checked first. A probe that prints both is treated as having refused, because reading the list from it would report saw nothing for a missing adapter (crates/runtime/session/src/probe.rs:309-311; asserted atcrates/runtime/session/src/probe.rs:383-390).foundMUST be an array. An empty array is a real answer: it means the probe looked and saw nothing. That is a different fact fromerror, which means it could not look (crates/runtime/session/src/probe.rs:20-23;crates/runtime/session/src/probe.rs:373-380).- Output carrying neither
foundnorerroris unreadable and is reported as such (crates/runtime/session/src/probe.rs:313-318). - Every field of a
foundentry is OPTIONAL and defaults to empty. Empty means the device did not say. A Bluetooth headband advertises a name and no serial; refusing it for that would refuse most devices (crates/runtime/session/src/probe.rs:31-35, and the fields at:43-77). detailMUST be a JSON object when present. It carries the vendor's own findings, is passed through unchanged, and is never interpreted by the runtime (crates/runtime/session/src/probe.rs:70-77; asserted at:407-436). Vendor findings MUST go underdetail— unrecognised keys elsewhere in afoundentry are silently dropped, becauseFoundneither denies unknown fields nor collects them.- A probe MUST write anything that is not the envelope to standard error,
not standard output. The runtime discards standard error and parses standard
output whole, so a progress line on the wrong stream makes the object
unparseable on exactly the devices that take longest to find
(
crates/runtime/session/src/probe.rs:229-243).
10.3 Exit and timeout
A probe MUST answer and exit. It is a question, not a monitor
(crates/runtime/session/src/probe.rs:117-120, NotAsked::NeverAnswered; 0033).
- A probe MUST close its standard output. The runtime reads that stream to end
of file on a separate thread, and a probe that prints its object and keeps the
stream open is indistinguishable from one that never answered
(
crates/runtime/session/src/probe.rs:264-273). - The runtime waits thirty seconds (
crates/runtime/session/src/probe.rs:168,PATIENCE). Thirty is five times the longest scan in this ecosystem, which ismuse's six-second Bluetooth sweep. - At the deadline the runtime kills the probe and reaps it, and reports
NeverAnsweredrather than an empty result (crates/runtime/session/src/probe.rs:280-289). Killing rather than leaving it is deliberate: a probe holding a Bluetooth adapter or a bound port would stop the node that needs it from starting. - A caller MAY state a shorter patience; the runtime uses
PATIENCE(crates/runtime/session/src/probe.rs:209-211,ask_within, which exists so a package can test that its own probe terminates).
Exit codes are not part of this contract. The runtime discards the probe's
exit status in both the answered and the timed-out path
(crates/runtime/session/src/probe.rs:277 and :284, both let _ = child.wait();). Decision 0033 states no exit code either. A probe MAY exit
non-zero after printing {"error": …} and MAY exit zero; nothing observes the
difference. A probe MUST NOT rely on its exit code being read, and MUST put every
fact it wants the runtime to learn in the JSON object. See §12.
10.4 When a probe may be run
A manifest MAY declare probing
(crates/runtime/session/src/registry.rs:206-226):
while-running— whether asking again, while this type's node is running, is safe and useful.because— REQUIRED whenwhile-runningis false, and refused at registry load when it is missing (crates/runtime/session/src/registry.rs:612-617). A flag switched off with no reason beside it is a flag the next author flips while chasing a stale reading, on a radio link they are not looking at.
An absent probing means a running node MUST NOT be re-probed. That is the safe
direction rather than the obvious one: a stale battery percentage is recovered by
filling in this field, and a Bluetooth scan across a live
electroencephalography link is not recovered at all
(crates/runtime/session/src/registry.rs:144-151).
11. Failure cases, collected
An implementation that handles the happy path and diverges on failure is worse than one that does not exist. This table is the index; each row's rule is above.
| Situation | Required behaviour | Where |
|---|---|---|
RANVIER_CONTROL absent or empty | Take the standard-input channel. Not an error | crates/runtime/session/src/launch.rs:76-81, crates/runtime/session/src/control.rs:470 |
RANVIER_CONTROL set, dial fails | Retry in the background on the period. Hold no bindings, publish nothing, open no instrument | crates/runtime/session/src/control.rs:816-828; 0036 §4 |
RANVIER_TOKEN absent | Send the empty string. Expect REFUSE_REASON_TOKEN | crates/runtime/session/src/launch.rs:89-91, crates/runtime/session/src/plane.rs:786-795 |
RANVIER_NODE_ID absent | Send some identity. Expect REFUSE_REASON_UNKNOWN_NODE from any runtime that did not expect it | crates/runtime/session/src/launch.rs:95-97, crates/runtime/session/src/plane.rs:799-810 |
RANVIER_PARAMS absent or blank | Empty settings map. Not an error | crates/runtime/session/src/launch.rs:294-299, :311-313 |
RANVIER_PARAMS unparseable | Report and exit non-zero | crates/runtime/session/src/launch.rs:314-316 |
No answer to Register | Bound the wait; proceed with no bindings; keep retrying | crates/runtime/session/src/control.rs:276, :544-555 |
Refuse received | Report, do not redial, exit non-zero | crates/runtime/session/src/control.rs:461-466, :970-973 |
Stop received in place of Bind | Stop waiting for the Bind; shut down | crates/runtime/session/src/control.rs:546, crates/runtime/session/src/plane.rs:871-877 |
Bind with an empty from | Bring the port up, do not dial, do not report an error | crates/runtime/session/src/control.rs:762-780 |
Bind that drops a stream | Close that connection | crates/runtime/session/src/control.rs:1113 |
Bind with heartbeat_period_ms of 0 | Keep the current period | crates/runtime/session/src/control.rs:948-950 |
| Connection closes mid-session | Keep every binding, keep publishing, redial on the period, resend Listening | crates/runtime/session/src/control.rs:816-828, :864-879 |
| Write to the control socket fails | Do not fail the node's own work upward; drop the connection and let the redial fix it | crates/runtime/session/src/control.rs:787-801 |
| Undecodable frame from the runtime | Ignore it rather than crashing | crates/runtime/session/src/control.rs:906-908 |
ToNode carrying no message | Ignore it | crates/runtime/session/src/control.rs:1010 |
Transport with accepts_transport false | Do not act; report | crates/runtime/session/src/control.rs:984-993 |
Transport with an unrecognised action | Do nothing at all | crates/runtime/session/src/control.rs:996-1000 |
A publisher named in a Bind cannot be reached | Report per stream, keep the others, retry on the next Bind | crates/runtime/session/src/control.rs:1121-1128 |
| Node exits for its own reasons | Send Stopping with STOPPING_REASON_COMPLETE first | crates/runtime/session/src/control.rs:717-721 |
| Probe prints prose | Reported as unreadable, with the first 400 characters quoted | crates/runtime/session/src/probe.rs:298-326, :174 |
| Probe does not exit | Killed at thirty seconds and reported as never answered | crates/runtime/session/src/probe.rs:275-290 |
And on the runtime's side, so an implementation knows what its mistakes look like from the other end:
| Node's mistake | Runtime |
|---|---|
A frame that is not a ToRuntime | Closes the connection |
Anything before Register | Closes the connection |
A second Register on one connection | Closes the connection |
A ToRuntime carrying no message | Ignores it, and does not count it as liveness |
| Silence for three periods | Reports the node gone; sends nothing; closes nothing |
Connection closes with no Stopping | Reports that the node left without saying so |
12. Not yet specified
Named rather than invented. An implementation MAY do anything reasonable here, and MUST NOT assume another implementation made the same choice. Closing one of these is a change to this document.
Timing nothing decided
- How long the runtime will wait for the first frame on an accepted
connection. There is no bound:
serveloops on a 50 ms read timeout with no identity and no deadline (crates/runtime/session/src/plane.rs:661-738). A socket that connects and says nothing costs a thread for the life of the session and is never reported, because it never enters the live set. An implementation MUST NOT rely on being disconnected for staying silent, and MUST NOT rely on being tolerated either. - How long a node waits for its first answer. Five seconds is this
implementation's number (
crates/runtime/session/src/control.rs:276), not a protocol constant. Nothing on the wire communicates it. - The redial cadence. The Rust redials on the heartbeat period, without
backoff, for ever (
crates/runtime/session/src/control.rs:816-828). Nothing requires that period, and the runtime does not police reconnection rate.
Behaviour nothing decided
- What a node does with
BindingInfo.device_id. The field travels and the Rust node ignores it entirely — see §5.2. - Whether a paused capture node drops samples, buffers them, or leaves a gap
the recording can show. Nobody has decided. The Rust recognises
pause, refuses it out loud for a node that did not declareaccepts_transport, and says so rather than going quiet (crates/runtime/session/src/control.rs:59-65;crates/runtime/session/tests/asking.rs:92-116). - What
Alive.sequenceis for. The.protosays a runtime seeing it go backwards is talking to a node that redialled (proto/ranvier/control/v1/control.proto:128-129). This runtime never reads the field — it counts arrivals instead (crates/runtime/session/src/plane.rs:972-976). Send a monotonically increasing per-connection sequence; do not expect it to be interpreted. - A probe's exit code. Nothing reads it
(
crates/runtime/session/src/probe.rs:277,:284). There is no convention to conform to and this document declines to invent one.
Machinery that does not exist
- The sequence half of the conformance corpus. One ordered transcript per
scenario — the direction of each frame and the expected end state — is
specified nowhere and exists nowhere. Only the encoding half exists
(
conformance/control/v1/encoding.json). The scenarios are Rust tests instead (crates/runtime/session/tests/plane.rs:34-49), which check this implementation and say nothing about a second one. Until those transcripts are written, ordering conformance is unenforced. The transcript file format is open too — three implementations will have to parse it. - Orphan cleanup. A Windows Job Object or a POSIX process group would do it; neither is specified and neither is built. A node whose runtime died keeps running by design, and nothing terminates it.
- Anything cross-machine. Every address here is loopback. The token in §2.2
is not authentication, and a control plane reachable from another host needs
authentication and encryption that do not exist. See
wire.md§3.4 — this is a block, not a deferral.
13. Conformance checklist
A node implementation conforms when it:
- Frames every message with a four-byte big-endian length, refuses a prefix above 16 MiB, and handles partial and coalesced reads (§1).
- Decodes all twelve fixture frames to their stated values, and round-trips each value (§1.2).
- Reads
RANVIER_CONTROL, treats empty as absent, and branches the channel on it alone (§2.1, §9). - Sends
Registeras the first frame, withprotocol = 1, the token verbatim, and the ports its own code declares (§4.1–§4.3). - Handles all five refusal reasons plus the unspecified value, and exits non-zero without redialling (§4.4, §4.5).
- Bounds its wait for the first answer, proceeds with no bindings on expiry,
and accepts a
Stopas a first answer (§4.5). - Replaces its binding set on every
Bind, treats an unchanged entry as a no-op, and closes connections to streams no longer named (§5.1). - Brings a port up without dialling when
fromis empty, and connects when a laterBindfills it in (§5.2, §5.4). - Sends
Listeningafter its listener is up, and resends it on every redial (§6.1). - Sends
Aliveon the periodBindgave it, from a thread that takes no lock the data path takes (§6.2). - Keeps publishing when the control connection closes, and redials (§7.5, §11).
- On
Stop: stops producing, drains, seals, repliesStopping, exits — insidedeadline_ms(§7.2). - Ignores
Transportit did not declare for, and does nothing on an unrecognised action (§8). - Never reads standard input as a control channel when
RANVIER_CONTROLis set (§9.1).
A probe conforms when it accepts --json after its manifest's arguments, prints
one object with found or error on standard output, keeps everything else on
standard error, and exits (§10).