Skip to main content

The node

What it takes to be a Ranvier node: the manifest you ship, the ports you declare, the settings you accept, and what your program does between being spawned and exiting.

Read wire.md and control.md alongside this. control.md specifies the protocol on the connection; this document specifies what the node owes that protocol and what it declares before anything runs.

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as described in RFC 2119.

How to read the citations

This document is authoritative. Where it and any other document in this repository disagree about what a node must do, 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 path:line or a check block. Re-derive it rather than trusting the sentence — line numbers move, and a specification that cannot be checked goes stale invisibly. docs/docs.tools/check-spec.py resolves every citation and runs every check block in continuous integration.

This repository ships no device integration. It is transport and runtime; a device integration and a modality's vocabulary belong to the modality, and are packaged separately. The worked examples this document cites — pulse-source, pulse-relay and pulse-sink, under crates/runtime/session/nodes/ — are what the manifest format and the control plane are exercised by here.

A consequence worth stating rather than hiding: where a rule is held down only by a test inside a device package, no build in this repository fails when that test goes. Such a rule is marked test-asserted below, which means exactly that and nothing stronger.

Three kinds of rule appear here, and they are not interchangeable. Each requirement says which it is:

MarkedMeans
parser-enforcedRegistry refuses the manifest at load, on a machine with none of the hardware attached. You cannot ship past it.
runtime-enforcedChecked when a session starts or when your node registers. You can ship past it; the session refuses.
test-assertedHeld down by a test inside one package. Nothing in the core checks it, and a package without that test is not covered.
conventionNeither. Followed by every shipped package and enforced by nothing.

A rule that is convention is still a rule — a SHOULD, or a MUST whose failure shows up somewhere other than a refusal. Where enforcement is absent, this document says so explicitly rather than letting a MUST imply a check that does not exist.


1. What a node is

A node is two things shipped together:

  1. A manifest — a YAML file named ranvier-node.yaml declaring one node type: what it is called, what to run, which ports it offers, and what it can be told.
  2. A program — the binary the manifest names, which registers with a runtime, publishes and subscribes on the ports it advertises, and stops when it is asked.

The split is load-bearing. A graph is checked before anything is spawned, on a machine that may have none of the instruments attached, so everything a graph needs to know MUST be readable without running the program [rvr_1pdfxj81uzzs min 2]. The cost of the split is that a manifest can drift from its binary, and §9.3 specifies where that drift is caught.

A node type is not a node. The manifest declares a type; a graph document instantiates it any number of times under different identifiers [rvr_tf8htkxrfoi3]. Two Muse headbands are two nodes of one type.


2. The manifest file

2.1 Name and location

The file MUST be named ranvier-node.yaml [rvr_c27dm6uxgp96]. No other name is read.

A registry is built by scanning one or more search roots. For each root, the scan considers exactly two places: <root>/ranvier-node.yaml, and <root>/<subdirectory>/ranvier-node.yaml for every immediate subdirectory [rvr_lst9vltvvh64]. It does not recurse further — a manifest two levels below a root is not found [rvr_0ky04af5pkte].

So a package MUST place each manifest either at a search root or exactly one directory below it. The shipped convention is one directory per type, named for the type:

find crates -name ranvier-node.yaml \
-not -path '*/target/*' -not -path '*/dist/*'
# crates/runtime/session/nodes/pulse-relay/ranvier-node.yaml
# crates/runtime/session/nodes/pulse-sink/ranvier-node.yaml
# crates/runtime/session/nodes/pulse-source/ranvier-node.yaml

Three manifests ship here, the runtime's own pulse-* worked examples. That directory-per-type naming is convention: the registry reads type: out of the file and never looks at the directory name.

Search roots are given to ranvier-run with --nodes <dir>, which is repeatable and defaults to ./nodes when that directory exists [rvr_vxlorbf3sxu0 min 2].

Anything in a search root that is not named ranvier-node.yaml is ignored. crates/runtime/session/nodes/two-processes.yaml is a graph document rather than a manifest. It sits beside manifests and is never read as one, because the scan matches on the filename alone. You MAY ship other YAML beside your manifests for the same reason.

2.2 One type per file, one type per registry

A file declares exactly one type. The format has no list form.

Two manifests declaring the same type: are refused — the registry does not pick one [rvr_nhmhs5jnnz3r min 2]. Resolution by scan order would make the same graph start differently on two machines.

2.3 Unknown fields are refused

Manifest, Probing, Where, PortSpec and ParamSpec all carry #[serde(deny_unknown_fields)] [rvr_v8h106ni6zfv min 5].

A manifest MUST NOT contain a field this specification does not list. A misspelled field is a Problem::Malformed naming the file and the parser's complaint [rvr_54of1vrl64gp], not a field silently ignored.

The consequence for you: there is no forward-compatible extension point. A manifest written for a newer runtime does not load on an older one. The format carries no version field, and what happens when it needs one is unspecified.

2.4 YAML that parses and YAML that does not

Two failure modes are worth naming because both were hit by package authors writing manifests, and neither is obvious:

  • Prose containing : breaks a plain scalar. A help reading does not travel: a graph… is natural to write and refuses to parse. Every summary, default and help SHOULD use a folded block scalar (>-). Recorded in docs/decisions/0035-a-manifest-says-where-and-the-setting-says-what-it-is-called.md:157-164; every shipped manifest follows it.
  • A numeric example must be quoted. Where::example is a String [rvr_2wev9fknsr11], so example: 12351 is refused and example: "12351" is not. A UDP port number is the case that hits this.

3. Manifest fields

Authoritative source: the Manifest type and Registry::add [rvr_8ywfw91d2yxp min 2]. Read those, not an example manifest — an example shows one type's choices, not the rules.

FieldYAML keyTypeRequiredAbsent means
typetypestringyesthe manifest is Malformed
commandcommandstringyesthe manifest is Malformed
portsportslistyesthe manifest is Malformed
labellabelstringnothe type identifier is the label
summarysummarystringnonothing is shown when choosing between types
vendorvendorstringnothere is no maker to name
argsargslist of stringsnono arguments before the runtime's
probeprobestringnothere is nothing to look for
probe-argsprobe-argslist of stringsnothe probe's default question
probingprobingblocknoa running node is not re-probed
locationlocationblocknothere is nothing to locate
paramsparamslistnothe type takes no settings

3.1 type — required

The identifier a graph document writes in type: [rvr_sznqxv41mfcz]. See §4.

Absent: Problem::Malformed, missing field \type``.

3.2 command — required

The program to run [rvr_d71vcsfqxygi].

Resolved beside the manifest first, then the search path [rvr_wvxg9iqw03hd min 2]. The resolution tries the name as written, then the name plus the platform's executable extension, then falls back to the bare name for the operating system to resolve.

So a manifest MUST name the command without a platform extension: write command: input, not command: input.exe. One manifest describes the package on all three platforms. Omitting the extension handling is what made beside-the-manifest resolution silently never match on Windows [rvr_wsfsaw8x0ywe].

A package SHOULD ship its binary beside its manifest, which is what makes it runnable with nothing installed.

Absent: Problem::Malformed. A command naming a program that does not exist is not caught at load — the spawn fails when the session starts [rvr_oi5ohwj9w2tq].

3.3 ports — required

The list of ports this type offers [rvr_azjpmc0kij1c]. No #[serde(default)], so the key MUST be present. An empty list (ports: []) parses and declares a type nothing can wire.

See §5.

3.4 label — optional

What this type is called for a person [rvr_8f6344heu944].

Empty means the type identifier is the label. That is a real answer for a type whose identifier reads well on its own, and wrong for one that does not: pupillabs-neon does not say Neon (Pupil Labs), and an interface guessing at that is how a stream name drifted once already.

A type whose identifier is not readable as a name SHOULD set label. Enforced nowhere: a manifest without one loads, and the type identifier stands in. All three manifests here set it:

grep -l '^label:' $(find crates -name ranvier-node.yaml \
-not -path '*/target/*' -not -path '*/dist/*') | wc -l # 3 of 3

3.5 summary — optional

One sentence on what this type does, for somebody choosing between two [rvr_gy2os3dzyv5u]. Same enforcement status as label: refused by nothing, and held down where it is held down at all by a test inside the package that ships the manifest. All three manifests here set it.

3.6 vendor — optional

Who makes the instrument, where there is a maker [rvr_67ky8ah5i839].

Empty is a statement, not an omission. A processor has no vendor, and neither does a device every computer has. A package whose types have no maker SHOULD assert the absence in its own tests, because an unchecked empty field is indistinguishable from work left undone — the next person filling in presentation fields would reasonably invent one. None of the three pulse-* types here declares a vendor, and none has one.

You MUST NOT invent a vendor for hardware that has none. Enforced by nothing: this is convention, held by whoever reviews the manifest.

3.7 args — optional

Arguments passed before any the runtime adds [rvr_co5qwlsz1ecp], used verbatim at spawn [rvr_32yorab19lex].

This is how one binary serves several types. ranvier-pulse ships one program and three manifests, distinguished only by args:

# crates/runtime/session/nodes/pulse-relay/ranvier-node.yaml
command: ranvier-pulse
args: ["--relay"]

A package shipping several types off one binary SHOULD assert in its own tests that each manifest starts that binary on its own role. A copied-and-edited manifest that forgot to change its arguments would run the same role twice, and nothing in the core would notice.

Absent: an empty list.

3.8 probe, probe-args, probing — optional

See §8. Three refusals live here, all parser-enforced:

  • probe-args with no probeProblem::ArgumentsForNoProbe [rvr_tnhqqxp3jljn].
  • probing with no probeProblem::ProbingForNoProbe [rvr_2ok0m3kurwnu].
  • probing.while-running: false with an empty or whitespace becauseProblem::NoReasonNotToProbe [rvr_8qa0rbic249l].

3.9 location — optional

See §7. One refusal, parser-enforced: a location.param naming a setting the type does not declare → Problem::NoSuchSetting, with the misspelling and the real setting names in the message [rvr_hfd4vrt69igp min 2].

3.10 params — optional

See §6. Absent and empty mean the same thing, and it is a strong claim: a type declaring no settings refuses every setting a document writes on it [rvr_y5if6q8mhdxd min 2].

That is different from a type nobody declared at all, which is reported as an unknown type instead [rvr_pc5h50ws1pqv].


4. The type identifier

4.1 What the parser requires

Nothing beyond being a string. Registry::add never inspects it except to key the map and detect duplicates [rvr_2oserzufd1at]. An empty type: loads.

The identifier is compared as an exact string when a graph resolves a node [rvr_kcxaeavund4n]. Case, punctuation and length are unspecified.

4.2 What the convention requires

The rule is vendor-and-model where there is a vendor:

  • <vendor>-<model> for a particular product from a particular maker.
  • devices- for what every computer already has: devices-mouse, devices-keyboard, devices-microphone, devices-screen.

The reasoning is that a type name is a lookup key rather than a description — its only requirements are uniqueness and permanence — and that vendor, model and serial are metadata carried in their own fields, which is where both LSL and BIDS put them. A model name alone is the shape that breaks: a rig with a second suit from a different maker has nowhere to put the distinction.

This is convention, not parser-enforced. A package holds it down for its own types or not at all:

assert_eq!(manifest().node_type, "sony-mocopi");

You SHOULD follow it. Nothing stops you doing otherwise, and nothing will tell you that you did.

4.3 The type your binary registers under

Your program sends a node_type in its Register [rvr_trrl9j7139id min 2]. It MUST be the same string its manifest declares.

The runtime does not check this. plane::accept compares protocol, token, node identity and ports, and never compares node_type [rvr_53kcf0lcl9qp]; the only use of the name is in the refusal message, where it is taken from the manifest side [rvr_wyo0j24flv1e]. A node that registers under a name its manifest does not declare is therefore accepted by a real plane::Listener expecting the declared name, and no test in this repository goes red.

So this is test-asserted only, and each package asserts it for itself. A node whose package carries no such test has nothing checking it at all. The failure it produces is a session that works and a refusal message naming a type nobody can find.

A binary that can publish more than one type's streams and is not spawnable as any of them SHOULD register under a name no manifest declares, and a test SHOULD assert that no manifest declares it.


5. Ports

A port is what your node's own code calls one of its connections. It is not a stream name: the port is fixed by the node type, and what it is bound to varies per session [rvr_6yb17u92ts8f].

5.1 Declaration

ports:
- name: reading
direction: out
schema: ranvier.example.v1.Reading
optional: true
KeyTypeRequiredMeaning
namestringyesthe port's local name [rvr_8jm367d31fmo]
directionin | outyesin consumes, out produces [rvr_ahhs7ikdxs2z min 2]
schemastringyesthe fully-qualified protobuf message name [rvr_srzj2610zbe5]
optionalbooleanno, default falsewhether the node runs without it bound [rvr_aojja28t4b3e]

direction is spelled in and out in the file, not with Rust's names [rvr_ldaxu0ihrezs]. Any other word is Malformed.

schema is required as a key and MAY be the empty string. It is not validated: no check that it is a well-formed protobuf name, and no check that any such message exists. The requirement that it be a fully-qualified protobuf name is convention [rvr_bmq8ukablnfx].

A manifest names a schema and MUST NOT carry its descriptor. There is no field for one [rvr_5l257v39ptd5]. The descriptor travels from the node itself when a stream is announced; what the registry needs identity for is comparing two ends of an edge, which is a string comparison.

5.2 Required and optional

optional: true in the manifest becomes required: false on the port the runtime builds [rvr_921fwj4ctozx].

A graph MUST bind every port that is not optional. A required port nothing binds is refused before anything is spawned, as WhyPort::Unbound [rvr_zxowhorlb2f0]. This is runtime-enforced at session start.

Declare a port optional when the node genuinely runs without it. Two cases that recur: a sensor that only some revisions of a device carry — a graph binding it on a device without one should record nothing there rather than refuse to start — and a stream whose capture is a decision a study makes rather than a default it inherits, such as video of a participant's face.

5.3 What the graph checks

Graph::check_ports runs before anything is spawned [rvr_lstuyc0wp9i5], given the ports the registry read. It refuses:

ProblemWhen
UnknownTypeno manifest declares the node's type [rvr_1cja4khvrcw7]
NoSuchPortpublish: names a port that is not an output, or subscribe: names one that is not an input [rvr_iaw4k3n1pwfz]
Unbounda required port is bound by nothing [rvr_y1wutpu3m0hc]
SchemaMismatcha subscriber's declared schema differs from the publisher's for the same stream name [rvr_q6v6ma3zzjpw]

Schema comparison uses the first publisher of a stream name [rvr_04hpldptszdo]. Several nodes publishing one name is valid and is not a mistake [rvr_ar2vmoqjb1ig].

An input naming no schema accepts any stream. That exemption is narrow and deliberate: naming a schema is how a port says what it will interpret, and a port naming none is claiming it interprets nothing [rvr_72huxhsdhv6i]. The recorder is the only case so far — it writes bytes it never decodes, and writes the schema the publisher announced beside them:

- name: input
direction: in
schema: ""
optional: true

An output port MUST name a schema. No exemption exists for one, and none is checked at load. The consequence appears one step later and only conditionally: an empty output schema is what gets recorded as the stream's identity [rvr_fum0htrinhdk], so any subscriber that does name a schema is refused with SchemaMismatch reporting an empty found. A graph whose subscribers also name nothing is accepted.

5.4 What your binary must advertise

Your program sends its ports in Register [rvr_g7f7h03x3ujh min 2], carrying name, direction, schema_identity and required.

The ports your binary advertises MUST match the ports its manifest declares. This is runtime-enforced: plane::accept compares the two and refuses the registration when they disagree, with one line per disagreement [rvr_hst922rautwf min 2].

What is compared, in both directions:

  • a port the manifest declares and the binary does not advertise [rvr_b7pwjlarknkc]
  • a port the binary advertises and the manifest does not declare [rvr_zhcg4jn1g1t9]
  • a direction that differs [rvr_2xdy17sylmmp]
  • a schema identity that differs, unless either side is empty [rvr_q7xrft093is9]

What is not compared:

  • required. A binary whose ports are required while its manifest calls them optional is two documents disagreeing about whether a device missing one sensor can run this graph, and nothing in the core reports it. A package SHOULD assert the agreement in its own tests.
  • node_type. See §4.3.
  • the descriptor. The runtime compares identities and never opens a descriptor [rvr_240ceesno4kk].

Advertise every port the type has, every time, not the subset this run was asked to serve. A binary told at the command line to serve two of its five sensors still advertises all five, because the manifest declares five and the comparison is by set.

5.5 What nothing checks

Nothing verifies that a declared port is ever published on. The manifest, the graph and the registration can all agree, and the binary can still never write a sample. That produces a stream the graph binds, the recorder declares, and nothing ever fills — a session that checks clean and then does nothing.

You MUST publish on every output port you declare and required, and you MUST read every required input. Enforcement is absent from the core entirely. A package SHOULD carry a test comparing its manifest against the constants its binary publishes under, because there is nothing else.

A port a binary cannot yet fill MUST NOT be declared, and its absence SHOULD be asserted, because a manifest is exactly where that lie would be told first: ranvier-run check would pass a graph binding a video port on a machine where no picture will ever arrive. By the same rule, a binary that refuses to start MUST NOT ship a manifest at all.

5.6 Port names

Any string. Shipped names include audio, motion, gaze.events, video.scene and input-16, so dots and hyphens are in use. There is no syntax rule and no length limit — unspecified.

A port name MUST NOT be reused within one node in the same direction. A graph binding one name in both publish: and subscribe: is refused as Why::DuplicatePort [rvr_vavo83m1dmxx].


6. Parameters

A setting is what a graph document writes under params: on a node. Declaring them is what turns a misspelling into a refusal instead of a node running on its defaults while the document — which is the record of the method — says otherwise [rvr_c6ahgklhsmjo].

6.1 Declaration

params:
- name: scope
kind: text
required: false
one-of: [own-window, window, everything]
label: Whose typing is recorded
default: >-
only while this node's own console window is in front
help: >-
Set it to `window` and name the stimulus program in `window`, and
everything typed into any other application is discarded before it
becomes a sample.
KeyTypeRequiredMeaning
namestringyeswhat the document writes left of the colon [rvr_prm6px47ot7f]
kindtext | whole | real | switchyeswhich scalar it takes [rvr_afyane96e52r min 2]
requiredbooleanno, default falsewhether the node refuses to run without it [rvr_pbm3n304uyfw]
one-oflist of stringsno, default emptythe permitted values [rvr_p3idujrwr040]
labelstringnowhat to call it for a person [rvr_nlc33yzifcv4]
defaultstringnoprose about what happens when it is left alone [rvr_pdy12ghlvb7c]
helpstringnowhy somebody would change it, and what goes wrong [rvr_98lrtfux5vrq]

ParamSpec also carries deny_unknown_fields [rvr_13fhe2lhd5uz].

default is prose, not a value. "every sensor the device offers", "the port the operating system chooses". A value here would be a second copy of the node's own fallback, and nothing keeps the two in step [rvr_12bl4vzbxx1u]. A package SHOULD assert this shape for its own settings.

6.2 What check_params refuses

Graph::check_params runs before anything is spawned, after check_ports so an unknown type is reported once [rvr_flw76hvlboor]. It is runtime-enforced and reports every problem, in document order [rvr_o2yfwi5taqtj]:

https://github.com/extendedresearch/ranvier/blob/main/spec/| Problem | When | Line | |---|---|---| | Unknown | the document sets a name the type does not declare; the message lists what it does declare | [rvr_nh6xjibh4vwi] | | WrongKind | the value is the wrong scalar | [rvr_pwnir45pl7q9] | | NotOneOf | one-of is non-empty and the value is not in it | [rvr_8eyafs09nn4s] | | Missing | a required: true setting was not given | [rvr_hitkbriw7gbt] |

Kind matching is not quite equality: a whole number is accepted where real is declared, because YAML writes 1 for a duration a reader means as 1.0. The reverse is refused — a real number where a count belongs is a rounding decision nobody wrote down [rvr_ktgyfdzo72gp min 2].

one-of comparison is against the value's Display form [rvr_sogotb1oz1g5], so one-of on a switch is matched against true/false and on a number against its decimal rendering.

6.3 How settings reach your node

Settings arrive in the environment variable RANVIER_PARAMS, as a JSON object mapping name to scalar ([rvr_v46ecgu227zm min 2], set at [rvr_t2scjcbcxmhi]).

Only settings the document actually wrote are present. Nothing fills in defaults — there are no default values to fill in, only prose. So your node MUST carry its own fallback for every optional setting it declares. The Rust helper reflects this: every accessor takes a fallback and none of them can fail [rvr_km074f5tlo8t min 2].

By the time RANVIER_PARAMS reaches you, the document has already been checked against your manifest, so a name you did not declare cannot have arrived and a value of the wrong kind cannot have arrived [rvr_k6lswwwecw8t].

A node run by hand has no RANVIER_PARAMS and runs on its own defaults; that is not an error [rvr_vpnz13akz7i2].

6.4 Four settings the core reads

Settings::attributed reads four optional settings and attaches them to a stream's announcement [rvr_cr21x14ryl9p]:

https://github.com/extendedresearch/ranvier/blob/main/spec/| Name | Kind | Means | |---|---|---| | operator | text | who the graph says is capturing | | authorisation | text | the protocol or approval this is captured under | | authorisation_from | whole | when that authority began, unix nanoseconds | | authorisation_until | whole | when it lapses |

A node that carries identity onto its streams SHOULD declare these four with exactly these names and kinds, and call the equivalent of attributed. The operator is recorded as configured — the weak claim, and the honest one, because a name written into a graph document was not asked for at the moment the session ran. An unset window is zero, meaning unstated rather than "began at the epoch" [rvr_un3xddfoj2oe min 2].

This is convention plus a per-package test. Nothing in the registry requires these names, and a node declaring operator as a whole would load.

6.5 What nothing checks

Nothing verifies that a declared setting is read by the binary, or that a setting the binary reads is declared. The second direction is at least loud — an undeclared setting is one no document can use, because check_params refuses it. The first is silent: a setting an interface renders, a researcher fills in, and the node never reads.

List both halves by hand in your package's own tests. There is nothing else.


7. Location

location says which setting carries the instrument's whereabouts, and what kind of where it is [rvr_zqwbf6hz911d]. What to call that setting and what to explain about it stay on the setting itself, so each fact has one home.

location:
param: device # MUST name a setting this type declares
kind: address # address | enumerated | port
example: 192.168.1.49
travels: false
KeyTypeRequiredMeaning
paramstringyesthe setting that carries it [rvr_kkdl9j97f7i7]
kindaddress | enumerated | portyeswhat kind of place [rvr_lohb92uixre5 min 2]
examplestringnoa value to show greyed out in an empty field [rvr_kujkmcwguv2t]
travelsbooleanno, default falsewhether a value here still works on another machine [rvr_r2auvbxhts6d]

7.1 When to declare one

Declare a location when there is something to locate. Omit it when there is not — a processor has no where, and a keyboard is wherever the computer is [rvr_32fferp96hvk].

The absence is a decision worth asserting in your own tests, because nothing distinguishes a deliberate omission from an oversight. Assert it in capitals if that is what it takes to stop the next person filling one in.

None of the three manifests here declares a location: the pulse-* examples have nothing to locate.

grep -l '^location:' $(find crates -name ranvier-node.yaml \
-not -path '*/target/*' -not -path '*/dist/*') | wc -l # 0

7.2 param — parser-enforced

param MUST name a setting the same manifest declares in params. A location naming a setting that does not exist is Problem::NoSuchSetting, and the message carries the misspelling and the real setting names [rvr_k2ff3og04gfi min 2].

The failure this closes is silent: an interface renders a field for a setting nothing reads, a researcher types an address into it, and the instrument is plugged in, configured, and mute [rvr_obvklmsk863c].

param naming a setting the binary never reads is not checked, and that is the same failure with the refusal removed. Assert in your own tests that the setting is not merely declared but is the one your binary binds.

7.3 kind

The list is short on purpose: an entry earns its place by making an interface behave differently, not by describing a transport [rvr_vsfr6zcfe1l1].

kindThe interface drawsShipped by
addressa text fieldpupillabs-neon
enumerateda list, filled from the probe's Found::iddevices-microphone, devices-screen, interaxon-muse
porta numbersony-mocopi

kind: enumerated SHOULD be paired with a probe, since the list an interface draws comes from the probe's answers [rvr_m3u6wp2a4kmh]. Nothing enforces the pairing.

7.4 example — not a default

example is what to show greyed out, not a value to fill in. An address has no sensible default: the empty value means look, and a filled-in value would be somebody else's phone [rvr_2yvqfmuriq91].

example MAY be omitted where the probe supplies the values, so there is no empty field to show one in. A type whose location is typed rather than chosen SHOULD carry one, and SHOULD assert that it does.

A numeric example MUST be quoted. See §2.4.

7.5 travels

travels: false says a value pinned here does not work on another machine. A WASAPI endpoint string or a Bluetooth address names a device to one computer, so a graph carrying one is not the portable artifact a graph beside a manuscript is supposed to be [rvr_0znfzgnxg6vx].

travels: true says the opposite, and there is exactly one: sony-mocopi binds a UDP port, and 12351 binds on the next laptop exactly as it binds here. What does not travel in that case is the phone's own configuration, and that is not in the document to travel (docs/decisions/0035-a-manifest-says-where-and-the-setting-says-what-it-is-called.md:145-155).

The default is false [rvr_qig0zedj7vof]. Nothing checks that the value is truthful.


8. The probe

A probe answers one question — is one of this type's instruments here — in one shape (docs/decisions/0033-a-probe-answers-one-question-in-one-shape.md).

8.1 When to declare one

A manifest MAY name a probe. Absent is ordinary — a processor has no hardware to look for, and a type declaring no probe is answered with NotAsked::NoProbe rather than treated as a fault [rvr_uee01cuexfjp min 2].

Declare a probe only if the program answers and exits. A package may ship a program it calls a probe that is really a live monitor — it listens and prints every frame until Ctrl-C. Decision 0033's contract is a program that answers and exits, and Registry::probe waits for that exit, so naming such a program would hang the runtime rather than tell it anything. Declare no probe in that case, and assert the absence: an absence nobody asserted is an absence somebody fills in. Better still, run the program you ship through the runtime's reader, so the reason is demonstrated rather than merely stated.

None of the three manifests here names a probe: the pulse-* examples have no instrument to look for.

grep -l '^probe:' $(find crates -name ranvier-node.yaml \
-not -path '*/target/*' -not -path '*/dist/*') | wc -l # 0

8.2 How the runtime runs it

Resolved beside the manifest first, with the platform's executable extension tried, then as a bare name [rvr_aes6kngw9fsw min 2].

Invoked as <probe> <probe-args…> --json [rvr_8l28qjrcfbre]. probe-args come before --json, because they select the question. One binary can answer more than one. An audio probe that enumerates capture devices by default and playback devices given --desktop-audio serves a microphone type and a screen type from one binary, each naming it with its own probe-args [rvr_m9b57n94fx5s].

Standard output is captured; standard error is discarded [rvr_w1zv4icoui2g]. Every probe that scans writes progress to standard error, so mixing them would make the object unparseable exactly on the devices that take longest to find.

Settings MAY be passed: Registry::probe_told sets RANVIER_PARAMS to the same JSON the node would receive, so a probe and its node read one set of settings with one piece of code [rvr_h64gzojx19bh min 2]. Registry::probe passes nothing [rvr_hfm9h0v19q1g]. A probe that reads nothing is unaffected.

8.3 The output contract

A probe MUST print exactly one JSON object on standard output, and it MUST exit (docs/decisions/0033-a-probe-answers-one-question-in-one-shape.md:88-96).

{
"found": [
{
"id": "NEON-0042",
"label": "Neon (Companion 3a4b)",
"serial": "NEON-0042",
"firmware": "2.8.3",
"address": "192.168.1.24",
"detail": {}
}
]
}
FieldMeansLine
idwhat a reconnect matches on — a serial where the instrument has one[rvr_gvkll5off7zv]
labelwhat a person reads in a list[rvr_okgjfgkw81l1]
serialas the device reports it[rvr_i352t1xho4r2]
firmwareas the device reports it[rvr_4pby8al3v89c]
addresswhere to reach it, when that is how it is reached[rvr_160z03a5yzs0]
detailthe vendor's own findings, carried and never interpreted[rvr_e05wymcl6ixh]

Every field MAY be empty, and empty means the device did not say. A Bluetooth headband advertises a name and no serial; refusing a device for not reporting a firmware would refuse most devices [rvr_lgvbtbhr8vlh]. Every field defaults, so a probe MAY omit any of them.

detail is carried to whatever displays a device and is never read by the core ([rvr_5ph4ug90ctys], asserted at [rvr_2o5sn5pkdwy1]). Put in it what only your package understands.

When you could not look, print {"error": "…"} instead. An empty found means the probe looked and saw nothing, which is a real and different answer: no adapter and no headband in range are different problems with different fixes [rvr_3qo565lx4w8k].

An object carrying both is read as the error ([rvr_rbjv97d3p7yn], asserted at [rvr_dh5clxwn81yr]). Reading the list from a probe that could not look would report "saw nothing" for a missing adapter.

A probe that was told nothing it needed SHOULD refuse rather than report an empty network: told no address, return error naming the missing setting rather than found: [].

8.4 Timing, and what the runtime does when a probe misbehaves

The runtime gives a probe 30 seconds (probe::PATIENCE, [rvr_b77ytl6mpb99]) — five times the longest scan any shipped probe performs, a six-second Bluetooth sweep.

Reading happens on another thread so the wait can be bounded, and so a probe printing more than the pipe holds does not block in write and never reach its own exit [rvr_3clnve6lqomz].

The probeThe runtime answers
will not startNotAsked::Unrunnable [rvr_tvvjy9trvsug]
has not answered at 30 skilled, then NotAsked::NeverAnswered [rvr_x2chsl5sutne min 2]
printed something that is not this envelopeNotAsked::Unreadable, quoting the first 400 characters [rvr_wkdr9nn42v50 min 3]
printed neither found nor errorNotAsked::Unreadable, no \found` and no `error`` [rvr_ivg3u0xg2ohs]
printed errorAnswer::Refusednot an error here; that is the probe answering [rvr_32d88nu4lq2j min 2]

A probe that overruns is killed rather than left, because one holding a Bluetooth adapter or a bound port would stop the node that needs it from starting [rvr_b0uvz70chyfj].

probe::ask_within and ask_told_within are public so a package can test that its own probe answers and exits under a short patience, which 0033 requires and nothing else checks [rvr_a34l6tqnqbmw]. Use them, and state in the test what your probe tolerates and why.

8.5 probing — when a probe may be run again

A manifest that declares a probe MAY say whether running it while its node is running is safe and useful [rvr_4dp4lhu8n1ol].

probing:
while-running: false
because: >-
Finding a headband means a Bluetooth scan. A connected peripheral stops
advertising, so a scan during a session reports the headband as gone, and
scanning costs airtime on the radio already carrying the EEG.
KeyTypeRequiredMeaning
while-runningbooleanyeswhether asking again during a session is safe and useful [rvr_3kibal78g97s]
becausestringwhen while-running is falsewhy not, in a sentence somebody reads before flipping the flag [rvr_5qt3acuvio0i]

Three rules, all parser-enforced:

  • probing without probe is refused [rvr_qkh25u44weri].
  • while-running: false with an empty or whitespace because is refused [rvr_lg85406i1h8t]. A flag switched off with no reason beside it is a flag the next author flips while chasing a stale reading.
  • because beside true is optional and permitted.

Absent means a running node is not 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 [rvr_7amoxbgmnhmz].

No shipped manifest declares probing:

grep -l '^probing:' $(find crates -name ranvier-node.yaml -not -path '*/target/*') | wc -l # 0

A package MAY lie here and nothing catches it: while-running: true on a Bluetooth type is obeyed (docs/decisions/0039-probing-is-not-free-and-the-manifest-says-when-it-is-not.md:212-215).

There is no per-type probe interval. while-running is a flag, not a cadence, and how often a supervisor asks a type that permits asking is not something a manifest can state — unspecified, and named as the field a second slow probe would want (docs/decisions/0039-probing-is-not-free-and-the-manifest-says-when-it-is-not.md:186-194).


9. Lifecycle

Unproven. Every requirement in this section is exercised by Rust nodes only. A second implementation has not run against it.

The protocol itself is control.md. This section is what your program owes it.

9.1 Spawn

The runtime resolves your command beside the manifest, spawns it with your args, and sets four environment variables [rvr_j432aqn81ahm]:

https://github.com/extendedresearch/ranvier/blob/main/spec/| Variable | Carries | Line | |---|---|---| | RANVIER_CONTROL | host:port of the runtime's loopback control listener | [rvr_bcfhlbrqvl96] | | RANVIER_TOKEN | the token you present when you register | [rvr_cpe3gpn10p14] | | RANVIER_NODE_ID | this node's identity in the graph | [rvr_u50lsph8et4z] | | RANVIER_PARAMS | the document's settings, as JSON | [rvr_snwsnmy0ym5a] |

The token is a separate variable so a diagnostic can print where a node is dialling without printing the secret [rvr_xle7ide55cx0].

Standard input is a pipe; standard output and standard error stay inherited, because a node prints where it is listening and an operator watching a session start is reading exactly those lines [rvr_70iq2sgc7ea9 min 2].

Bindings arrive on the connection, and nothing is passed in the environment, which is what lets a subscriber be told an address nobody knew when the process started [rvr_teg2q0en3k1u].

9.2 Join

RANVIER_CONTROL decides which channel you are on, and the two are mutually exclusive by construction [rvr_x11hgiqdez05 min 2]:

https://github.com/extendedresearch/ranvier/blob/main/spec/| | Channel | |---|---| | RANVIER_CONTROL set | the connection. Bind, Transport and Stop arrive on it | | RANVIER_CONTROL unset | standard input. The node is being run by hand |

A node with no RANVIER_CONTROL MUST still run. Being started by hand is a supported way to run one [rvr_pz9ep8h20eu8 min 2]. It runs on its own direct declarations and its own defaults.

On the standard-input channel your node MUST read lines and treat stop as the ask to stop. End of input is not a stop — a node started with no terminal would otherwise exit the instant it began [rvr_8iu2x8cixzya min 2]. An empty line is nothing; an unrecognised word is reported on standard error rather than acted on.

9.3 Register

You dial, and you speak first: one listener serves the whole graph, so an accepted socket is anonymous until you name yourself [rvr_hagziasidnkp].

Register carries protocol, token, node identity, node type, package version, process identifier, whether you take transport actions, the stop deadline you ask for, and your ports [rvr_sspp38tnizrs].

Register before you touch your instrument. Nothing in a registration needs hardware — the ports come from your own code and the type from your manifest — which is what makes the runtime's join timeout a bound on process start rather than on device discovery. This is a rule rather than an observation [rvr_nsniq0kq4afi].

The runtime refuses a registration for five reasons, in order [rvr_kzmi15axnjx1]:

https://github.com/extendedresearch/ranvier/blob/main/spec/| Reason | When | |---|---| | Protocol | your protocol is not the runtime's [rvr_qdv3peiw29sw min 2] | | Token | your token is not this session's [rvr_sgiblvw3juea] | | UnknownNode | no node of that identity is part of this session [rvr_bnwoqzp4b0hu] | | AlreadyRegistered | that identity is live on another connection [rvr_rjq88g04gqzy] | | Ports | your ports and your manifest disagree (§5.4) [rvr_ht4frxnxadhk] |

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 [rvr_hk5y3rqiiide].

A node that cannot reach the runtime is a different case and MUST NOT treat it as fatal: it retries in the background, holds no bindings, publishes nothing, and opens no instrument [rvr_kjoepvhd8gt9]. The Rust helper waits up to JOIN_PATIENCE — five seconds — for a first answer before returning [rvr_061f1au2mbk3 min 2].

package_version is sent and the runtime reads it nowhere:

grep -n 'package_version' crates/runtime/session/src/plane.rs # no matches

9.4 Run

Bindings arrive in Bind, and Bind replaces rather than merges [rvr_s2ibek8yikbk min 2]. A stream the runtime stops naming is one you MUST disconnect; holding a connection the runtime has forgotten is the stale binding the replace rule exists to make impossible.

A subscribe binding whose from is empty is not an error. It is a port bound to a stream nobody has published yet: the port comes up subscribed and not connected, and a later Bind carries the address [rvr_w3pppb4iwrnw]. You MUST be able to take an address that arrives after you started, which is what watching the binding generation is for [rvr_9z4ghdn4vzgx].

Publish under the name the Bind gave you, not under your own constant. That is what lets a document rename a stream, and it is how two microphones in one room become two streams a person can tell apart. Where a graph named no stream for one of your ports, fall back to your own default.

Once your publishers are up, say so: Listening { address, published } is what tells the runtime where you landed, and it is what a subscriber elsewhere in the graph is waiting for [rvr_s882zlm5vemq]. Resend it on every redial, so a runtime that came back learns where you are without asking [rvr_z01fsmwdzb7b].

Keep beating. Alive goes on the period the runtime asked for in Bind, defaulting to two seconds, and three consecutive misses mean gone [rvr_ulnvjvdsyckb min 2]. The heartbeat MUST NOT queue behind your data path: a beat that waited on a 1 kHz publisher's delivery loop would report the node unhealthy at the moment it was working hardest [rvr_tmv54lf0x8u3].

If the connection drops, keep publishing and redial. A node whose runtime went away keeps every binding, keeps publishing, and reconnects on the heartbeat period without backoff [rvr_m54wohkccnmh min 2].

Transport { PAUSE | RESUME } is delivered only if you declared accepts_transport. A node that did not declare it says so on standard error and acts on nothing [rvr_1ssjyijaoo7m]. The runtime does not gate on the flag — grep -n accepts_transport crates/runtime/session/src/plane.rs returns nothing — so refusing it is your side's job.

What a capture node should do about a pause is not decided. A recorder pauses by not writing, which is well defined; whether a capture node drops the samples, buffers them, or leaves a gap the recording can show is unspecified, and inventing an answer would put it in five packages at once [rvr_716qdvbf2hia].

9.5 Stop

Stop { deadline_ms } arrives on the connection; on standard input the ask is the line stop, which carries no deadline [rvr_9m50vvmcqqmb min 2].

You MUST exit within the deadline. The default is five seconds [rvr_3fx9trhv68yi], and a request for longer is capped at 30 seconds — probe::PATIENCE reused, so there is one answer to how long will the runtime wait for a child rather than two that can drift [rvr_tj3beixwka18].

Say Stopping before the process exits. That is the whole distinction a session report exists to draw — a node that answered was stopped, and a node that did not was killed [rvr_jf7te3qe9mnq].

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, and a node that ignores the ask does not delay one that took it [rvr_xsf9immiyc95].

What the runtime reports [rvr_7ht0do22q794]:

https://github.com/extendedresearch/ranvier/blob/main/spec/| Outcome | Means | |---|---| | Stopped | asked, and gone inside the deadline. A recorder that ended this way sealed its file | | Ignored | asked, still running at the deadline, killed | | Unreachable | killed without ever having been asked, because no channel reached it |

Unreachable claims nothing about your shutdown behaviour: you had no Stop to answer [rvr_epo3ew6jjm56].

Being stopped is not the only ending. A node MAY end for its own reasons — a capture thread that died, an input whose publisher went away — and SHOULD route that through the same path as an ask so one code path leaves the program rather than two that drift [rvr_9ajh6e866uw8].

What happens next is the graph's decision, not yours: on-exit is per node and defaults to restart [rvr_eu207ygmrrb9 min 2]. A restart re-runs the original instruction rather than reconstructing one [rvr_kuu5klbpglbl].

9.6 Placement

A manifest says nothing about where a node runs. Placement is the graph's, in on: [rvr_qjsvqvb6ylwn], and decision 0017 makes it part of the document.

A node placed anywhere other than the runtime reading the document is refused today, as WhyNot::Elsewhere [rvr_2ov11fma2bhp min 2]. Reaching another runtime needs authentication and cross-host clock synchronisation, and neither is built. You have nothing to declare about this.


10. Checklist

Work through this to be usable in a graph.

The manifest

  • Named ranvier-node.yaml, at a search root or one directory below it (§2.1)
  • type, command and ports present (§3)
  • type follows §3.1 naming — vendor-and-model, or devices- (§4.2)
  • command names the program without a platform extension (§3.2)
  • No field this document does not list (§2.3)
  • summary, default and help written as folded block scalars >- (§2.4)
  • label and summary set, unless the identifier reads as a name (§3.4, §3.5)
  • vendor set if and only if there is a maker (§3.6)

Ports

  • Every port your binary publishes on or reads from is declared (§5.5)
  • No port declared that your binary cannot fill (§5.5)
  • Every output names a schema; an input names one unless it interprets nothing (§5.3)
  • optional: true on every port the node genuinely runs without (§5.2)
  • Your Register advertises exactly this set, every run (§5.4)

Parameters

  • Every setting your binary reads is declared (§6.5)
  • No setting declared that your binary never reads (§6.5)
  • Each has label, default and help (§6.1)
  • default is prose about what happens, not a copy of the value (§6.1)
  • one-of on every setting that selects behaviour (§6.2)
  • operator and the three authorisation settings declared, if your streams carry identity (§6.4)
  • Your node supplies its own fallback for every optional setting (§6.3)

Location

  • location present if there is something to locate, absent if not (§7.1)
  • param names a setting you declare and your binary reads (§7.2)
  • kind matches how a person picks the value (§7.3)
  • example quoted if numeric; omitted where a probe supplies values (§7.4)
  • travels honest (§7.5)

Probe

  • Declared only if the program answers and exits (§8.1)
  • Prints one object: found, or error when it could not look (§8.3)
  • probe-args select the question, and are declared where one binary answers several (§8.2)
  • probing.because filled in wherever while-running is false (§8.5)

Lifecycle

  • Runs with RANVIER_CONTROL unset (§9.2)
  • Registers before touching the instrument (§9.3)
  • Registers under the type its manifest declares (§4.3)
  • Publishes under the name Bind gave, not its own constant (§9.4)
  • Reports Listening once its publishers are up, and again on every redial (§9.4)
  • Beats on the period Bind asked for, off the data path (§9.4)
  • Takes a Bind that arrives later and fills in an address (§9.4)
  • Keeps publishing and redials when the connection drops (§9.4)
  • Says Stopping, then exits inside the deadline (§9.5)

Tests, because the core checks none of the above's silent half

  • Load your own manifests through Registry::scan, not a YAML parser of your own
  • Compare declared ports against what your binary registers, in both directions
  • List the settings your binary reads, by hand, and assert each is declared
  • Run your own probe through probe::ask_within and probe::read
  • Assert your deliberate absences — no vendor, no location, no probe

11. Not yet specified

These are holes, not permissions. Do not depend on what the Rust does here. Closing one of these is a change to this document.

HoleWhy it is open
Type identifier syntaxAny string loads, including empty (§4.1). §3.1's convention is a naming rule, not a grammar.
Port name syntaxAny string. Dots, hyphens and digits are all in shipped names (§5.6).
Schema identifier validationNothing checks it is a well-formed protobuf name, or that the message exists (§5.1).
Manifest versioningNo version field, and deny_unknown_fields means a newer manifest does not load on an older runtime (§2.3).
Probe cadencewhile-running is a flag, not an interval. How often a permitted probe is asked is the supervisor's business, and no manifest can state it (§8.5).
What a capture node does on pauseUndecided (§9.4).
Whether command may be an absolute path or carry argumentsResolution joins the manifest's directory with the string and otherwise passes it to the operating system (§3.2). No shipped manifest tries either.
Two manifests in one directoryImpossible by the filename rule, and nothing says what a package with several types in one directory should do instead (§2.1, §2.2).
Exit codesA refused node SHOULD exit non-zero (§9.3). Nothing else specifies a code, and nothing reads one except to render it in a report.
How a probe synthesises an id for an instrument with no serialEach package invents its own today.

No shipped manifest carries a probing block, so §8.5's rules are enforced by the parser and exercised by nothing. The check below runs in continuous integration and covers this repository's three manifests:

grep -l '^probing:' $(find crates -name ranvier-node.yaml -not -path '*/target/*') | wc -l

A manifest shipped elsewhere is covered by nothing here.


12. Worked examples

12.1 The smallest manifest that loads

type: example-nothing
command: example-nothing
ports: []

Three keys, because three are required (§3). This declares a type that takes no settings, offers no ports, and therefore cannot be wired into anything. It is here to show the floor, not as a thing to ship.

12.2 The smallest useful manifest

type: pulse-source
label: Pulse (source)
summary: Publishes a counter on a port the operating system chooses.
command: ranvier-pulse
args: ["--source"]
ports:
- name: beat
direction: out
schema: ranvier.example.v1.PointerSample
params:
- name: device
kind: text
- name: rest_ms
kind: whole

Shipped verbatim as crates/runtime/session/nodes/pulse-source/ranvier-node.yaml. One output, two settings, no probe, no location — a node with nothing to find and nowhere to be. Note that its settings carry no label, default or help: this is a worked example inside the runtime rather than something a researcher configures, and the presentation fields §3.4 and §6.1 ask for are exactly what it omits.

12.3 A realistic device manifest, annotated

A worn EEG headband, with the help and default prose abridged where marked. No such manifest ships here — this repository ships no device integration — so read this for the shape rather than as a file to open.

The schema prefix canonical.v1. is a stand-in. A device integration names the vocabulary its modality publishes; the only vocabulary this repository ships is ranvier.example.v1, and it exists to exercise the wire rather than to describe an instrument.

type: interaxon-muse # vendor-and-model, §3.1. Nothing enforces it;
# a test in the package asserts it.
label: Muse # the type identifier is not a name a person reads
vendor: Interaxon # there is a maker, so name it
summary: Scalp potentials, movement and pulse from a worn EEG headband.

location: # a headband has to be found before it is used
param: device # MUST be one of `params` below — parser-enforced
kind: enumerated # picked out of a scan, so an interface draws a list
example: AA:BB:CC:DD:EE:01 # shown greyed out; not a default
travels: false # the identifier is the host Bluetooth stack's,
# so a graph pinning one does not run elsewhere

command: muse # resolved beside this file first, then the path
probe: muse-probe # answers and exits, so it may be named

ports: # every port the binary registers, always all five
- name: eeg
direction: out
schema: canonical.v1.EegChunk
optional: true # a Muse 1 has no optical sensor; a graph binding
- name: acc # `ppg` on one should record nothing rather than
direction: out # refuse to start
schema: canonical.v1.ImuSample
optional: true
- name: gyro
direction: out
schema: canonical.v1.ImuSample
optional: true
- name: ppg
direction: out
schema: canonical.v1.PpgChunk
optional: true
- name: device
direction: out
schema: canonical.v1.DeviceState
optional: true

params:
- name: device # the setting `location.param` names
kind: text
label: Headband
default: >-
the first headband the scan turns up, which is right for a rig with one
and wrong for a rig with two
help: >-
Pick the headband out of a scan. … The identifier is the host Bluetooth
stack's — a MAC address on Windows and Linux, an opaque per-machine
identifier on macOS — so it is not a serial number and it does not travel.
- name: sensors
kind: text
label: Sensors to publish
default: >-
eeg, acc, gyro and device — everything except the optical sensor
help: >-
Comma-separated, in the words the ports are called … A name this does not
know is refused at startup rather than skipped.
- name: port
kind: whole
label: Publisher port
default: >-
the port the operating system chooses, which the runtime reads off this
node's first line of output
help: >-
Fix it when something outside this graph has to dial in …
- name: operator # §6.4 — read by `Settings::attributed`
kind: text
label: Operator
default: >-
nobody recorded, rather than an identity nothing backs
help: >-
Who is capturing … recorded as `configured`, which is the weak claim.
- name: authorisation
kind: text
label: Authorisation
default: >-
no authority recorded on the streams
help: >-
The protocol number or approval this capture is made under …
- name: authorisation_from
kind: whole
label: Authorisation valid from
default: >-
unstated, which is a complete answer …
help: >-
When the authority above began, in unix nanoseconds …
- name: authorisation_until
kind: whole
label: Authorisation valid until
default: >-
unstated, the same as its start …
help: >-
When the authority above lapses, in unix nanoseconds …

Three things this manifest does not do, each deliberate:

  • No probing block. 0039 §5 says this type should carry while-running: false with the Bluetooth reason. It does not yet, and until it does the supervisor does not re-probe it — which is the safe direction (§8.5).
  • No one-of on sensors. The value is a comma-separated list rather than a choice, and one-of matches whole values (§6.2). The binary refuses an unknown name at startup instead.
  • No required setting. Every one is optional, and the binary carries the fallback (§6.3).