Skip to main content

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 → runtimeRuntime → node
RegisterBind
ListeningRefuse
AliveStop
StoppingTransport

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 frame to the stated value. 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).

VariableFormatMust a node read it?
RANVIER_CONTROLhost:port — the runtime's control listenerMUST
RANVIER_TOKENan opaque stringMUST, when RANVIER_CONTROL is set
RANVIER_NODE_IDthe node's identity in the graphSHOULD
RANVIER_PARAMSa JSON object of settingsMAY

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:port and 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_CONTROL is 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, the else branch of join).
  • 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 with REFUSE_REASON_TOKEN because 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 at crates/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 to None).
  • When it is absent, an implementation MUST send some identity in Register.node_id anyway; the field has no absent form. The Rust falls back to the program name its caller passed to join (crates/runtime/session/src/control.rs:501). Any fallback is permitted, and it will be refused as unknown by any runtime that did not expect that 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):

FieldRequirement
protocol (uint32)MUST be 1 for this version (crates/runtime/session/src/plane.rs:72, PROTOCOL)
tokenMUST be RANVIER_TOKEN verbatim (§2.2)
node_idMUST be the node's identity in the graph (§2.3)
node_typeSHOULD be the type its manifest names
package_versionSHOULD 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.
  • directionPORT_DIRECTION_IN or PORT_DIRECTION_OUT. MUST be set; a port advertised as PORT_DIRECTION_UNSPECIFIED disagrees with any manifest that declares a direction and is refused (§4.4).
  • schema_identity — the fully-qualified protobuf message name, such as ranvier.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_identity MAY 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 from Port::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:

RefuseReasonWhenWhere
REFUSE_REASON_PROTOCOLRegister.protocol is not the runtime'scrates/runtime/session/src/plane.rs:774-784
REFUSE_REASON_TOKENRegister.token does not match the session'scrates/runtime/session/src/plane.rs:786-795
REFUSE_REASON_UNKNOWN_NODEno node of that node_id is part of this sessioncrates/runtime/session/src/plane.rs:799-810
REFUSE_REASON_ALREADY_REGISTEREDa node of that node_id is connected on another connectioncrates/runtime/session/src/plane.rs:812-823
REFUSE_REASON_PORTSthe manifest and the binary disagree about portscrates/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 Bind restated 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, and crates/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.
  • directionPORT_DIRECTION_OUT means publish; PORT_DIRECTION_IN means 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 sends Alive. 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 Bind has said otherwise, a node MUST use a default period. The Rust uses two seconds (crates/runtime/session/src/plane.rs:83, HEARTBEAT_PERIOD; applied at crates/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):

  • addresshost:port where 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 send Listening with an empty address; the worked example does (crates/runtime/session/src/bin/pulse.rs:224).
  • published — one Published { stream, device_id } per stream the node now publishes. device_id is 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):

CaseRuntime
Stream not seen beforeRecords address and device_id
Known stream, held device_id empty or reported device_id emptyTakes the new address; adopts the reported device_id if it had none
Known stream, both device_ids set and differentKeeps 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:896 and :925-929).
  • Alive MUST 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 is crates/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 send Alive on 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 x and 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 empty from, 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.
  • reasonSTOP_REASON_SESSION_ENDING or STOP_REASON_PEER_FAILED. This runtime sends STOP_REASON_SESSION_ENDING in both places it constructs a Stop (crates/runtime/session/src/plane.rs:894-901, stopping), so STOP_REASON_PEER_FAILED is declared and unsent today. A node MUST handle every value, including STOP_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):

  1. Stop producing.
  2. Drain what it already holds.
  3. 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).
  4. Reply Stopping.
  5. 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):

  • reasonSTOPPING_REASON_ASKED if the node is leaving because it was asked, STOPPING_REASON_COMPLETE if its own work finished, STOPPING_REASON_FAULT if something went wrong. The Rust helper sends ASKED when the stop flag is raised and COMPLETE otherwise, and never sends FAULT (crates/runtime/session/src/control.rs:717-721). An implementation MAY send FAULT; 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):

OutcomeMeaning
StoppedAsked, and gone inside the deadline
IgnoredAsked, still running at the deadline, killed
UnreachableKilled 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 Register arrives after the session was told to end is sent a Stop in place of its Bind and is counted as asked (crates/runtime/session/src/plane.rs:871-877; crates/runtime/session/tests/plane.rs:1090-1148). Handle a Stop as the first message on a connection. An implementation that only handles Stop after a successful Bind will 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 Stopping before it closes the connection or exits. A connection that closes with no Stopping is 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_transport to true only if it acts on these.
  • A node that did not declare accepts_transport and 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 action value 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 emptyStandard 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_CONTROL alone. 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; and crates/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_CONTROL is 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):

LineBehaviour
stopThe node is asked to stop, and the reader returns. Nothing after it is read (crates/runtime/session/src/control.rs:1162-1165)
pause / resumeHonoured, as Transport actions, only if the node declared accepts_transport (crates/runtime/session/src/control.rs:1166-1184)
pause / resume, node did not declareRecognised, refused, and said out loud on standard error (crates/runtime/session/src/control.rs:1167-1174)
an empty or whitespace-only lineNothing (crates/runtime/session/src/control.rs:1185)
anything elseReported 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 at crates/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
  • --json MUST be accepted, and MUST come after any probe-args from the manifest (crates/runtime/session/src/probe.rs:234-243). The arguments select the question — one binary can answer more than one, and av's probe enumerates capture devices by default and playback devices given --desktop-audio (crates/runtime/session/src/registry.rs:132-141).
  • RANVIER_PARAMS MAY 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-args or probing without declaring a probe is 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"}
  • error is 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 at crates/runtime/session/src/probe.rs:383-390).
  • found MUST be an array. An empty array is a real answer: it means the probe looked and saw nothing. That is a different fact from error, 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 found nor error is unreadable and is reported as such (crates/runtime/session/src/probe.rs:313-318).
  • Every field of a found entry 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).
  • detail MUST 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 under detail — unrecognised keys elsewhere in a found entry are silently dropped, because Found neither 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 is muse's six-second Bluetooth sweep.
  • At the deadline the runtime kills the probe and reaps it, and reports NeverAnswered rather 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 when while-running is 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.

SituationRequired behaviourWhere
RANVIER_CONTROL absent or emptyTake the standard-input channel. Not an errorcrates/runtime/session/src/launch.rs:76-81, crates/runtime/session/src/control.rs:470
RANVIER_CONTROL set, dial failsRetry in the background on the period. Hold no bindings, publish nothing, open no instrumentcrates/runtime/session/src/control.rs:816-828; 0036 §4
RANVIER_TOKEN absentSend the empty string. Expect REFUSE_REASON_TOKENcrates/runtime/session/src/launch.rs:89-91, crates/runtime/session/src/plane.rs:786-795
RANVIER_NODE_ID absentSend some identity. Expect REFUSE_REASON_UNKNOWN_NODE from any runtime that did not expect itcrates/runtime/session/src/launch.rs:95-97, crates/runtime/session/src/plane.rs:799-810
RANVIER_PARAMS absent or blankEmpty settings map. Not an errorcrates/runtime/session/src/launch.rs:294-299, :311-313
RANVIER_PARAMS unparseableReport and exit non-zerocrates/runtime/session/src/launch.rs:314-316
No answer to RegisterBound the wait; proceed with no bindings; keep retryingcrates/runtime/session/src/control.rs:276, :544-555
Refuse receivedReport, do not redial, exit non-zerocrates/runtime/session/src/control.rs:461-466, :970-973
Stop received in place of BindStop waiting for the Bind; shut downcrates/runtime/session/src/control.rs:546, crates/runtime/session/src/plane.rs:871-877
Bind with an empty fromBring the port up, do not dial, do not report an errorcrates/runtime/session/src/control.rs:762-780
Bind that drops a streamClose that connectioncrates/runtime/session/src/control.rs:1113
Bind with heartbeat_period_ms of 0Keep the current periodcrates/runtime/session/src/control.rs:948-950
Connection closes mid-sessionKeep every binding, keep publishing, redial on the period, resend Listeningcrates/runtime/session/src/control.rs:816-828, :864-879
Write to the control socket failsDo not fail the node's own work upward; drop the connection and let the redial fix itcrates/runtime/session/src/control.rs:787-801
Undecodable frame from the runtimeIgnore it rather than crashingcrates/runtime/session/src/control.rs:906-908
ToNode carrying no messageIgnore itcrates/runtime/session/src/control.rs:1010
Transport with accepts_transport falseDo not act; reportcrates/runtime/session/src/control.rs:984-993
Transport with an unrecognised actionDo nothing at allcrates/runtime/session/src/control.rs:996-1000
A publisher named in a Bind cannot be reachedReport per stream, keep the others, retry on the next Bindcrates/runtime/session/src/control.rs:1121-1128
Node exits for its own reasonsSend Stopping with STOPPING_REASON_COMPLETE firstcrates/runtime/session/src/control.rs:717-721
Probe prints proseReported as unreadable, with the first 400 characters quotedcrates/runtime/session/src/probe.rs:298-326, :174
Probe does not exitKilled at thirty seconds and reported as never answeredcrates/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 mistakeRuntime
A frame that is not a ToRuntimeCloses the connection
Anything before RegisterCloses the connection
A second Register on one connectionCloses the connection
A ToRuntime carrying no messageIgnores it, and does not count it as liveness
Silence for three periodsReports the node gone; sends nothing; closes nothing
Connection closes with no StoppingReports 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

  1. How long the runtime will wait for the first frame on an accepted connection. There is no bound: serve loops 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.
  2. 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.
  3. 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

  1. What a node does with BindingInfo.device_id. The field travels and the Rust node ignores it entirely — see §5.2.
  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 declare accepts_transport, and says so rather than going quiet (crates/runtime/session/src/control.rs:59-65; crates/runtime/session/tests/asking.rs:92-116).
  3. What Alive.sequence is for. The .proto says 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.
  4. 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

  1. 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.
  2. 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.
  3. 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 Register as the first frame, with protocol = 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 Stop as 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 from is empty, and connects when a later Bind fills it in (§5.2, §5.4).
  • Sends Listening after its listener is up, and resends it on every redial (§6.1).
  • Sends Alive on the period Bind gave 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, replies Stopping, exits — inside deadline_ms (§7.2).
  • Ignores Transport it did not declare for, and does nothing on an unrecognised action (§8).
  • Never reads standard input as a control channel when RANVIER_CONTROL is 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).