Streams
What a Ranvier stream guarantees: how samples are numbered, how loss is counted, which clock each timestamp is on, and what a connection says about a stream before it delivers one.
Why this document exists
Get gap accounting subtly wrong in a binding and nothing fails. The samples decode, the counters read plausibly, the tests pass, and the loss numbers in the resulting study are not comparable with the ones the Rust runtime produces for the same session. A tracker keyed on the sequence number alone reports duplicates that never happened. A tracker that observes after the queue instead of before it folds its own drops into the count of what the network lost. A tracker with a reorder window of 256 instead of 64 reports 197 missing where Rust reports 198. Each of these is a working program.
That is the failure this document prevents. Everything below is read off the implementation, and every requirement carries the file and line it came from so you can check it rather than trust it.
Conformance language
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
Requirements are addressed to two parties:
- A publisher-side implementation — anything that assigns sequence numbers, stamps timestamps, or announces a stream.
- A subscriber-side implementation — anything that receives samples and reports what it received: a binding, a viewer, a recorder, an analysis node.
Where the Rust runtime does something that no requirement forces, this document says MAY and names it as an implementation choice. Where something is genuinely undecided, it appears in §10 rather than being invented here.
How to read this, and how to check it
This document is authoritative. Where it and any other document in this
repository disagree about what a stream guarantees, 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.
The framing these samples travel in is specified in wire.md, and
what a node owes the runtime in node.md.
Short file names below resolve as:
| Where these live | Path |
|---|---|
sample.proto, stream.proto | proto/ranvier/message/v1/ |
Line numbers drift. Each requirement that pins one also names something
greppable, and docs/docs.tools/check-spec.py resolves every citation in
continuous integration. To exercise the behaviour itself:
cargo test -p ranvier-node --lib
cargo test -p ranvier-node --test delivery --test announcement --test adopted
The worked examples below are drawn from those tests.
1. Terms
Stream — one publisher's series of samples. A stream owns exactly one sequence counter.
Name — what a subscriber asks for. A name is not unique and several
streams MAY publish under one; that is the documented ordinary case rather
than an edge (0040 §4).
stream_ref — a uint32 label the announcing runtime allocates, carried
on every sample and on the announcement that describes it. It resolves against
announcements from that runtime only.
Connection — the arrival path a sample took. In-process delivery is one;
each socket is one; each adopted (replayed) stream is one. Rust calls this a
Source (crates/runtime/node/src/gap.rs:64-78).
Sample — one observation: two timestamps, a sequence number, opaque
values, and a stream_ref (sample.proto:33-165).
2. Sequence numbers
2.1 Assignment
A publisher-side implementation MUST assign the first sample of a stream
sequence 1, and MUST increment by exactly 1 per published sample
(crates/runtime/node/src/lib.rs:935, counters.sequence += 1, with the sample built at
crates/runtime/node/src/lib.rs:941-950).
Sequence 0 MUST NOT appear on a published sample. proto3 omits a zero-valued
scalar, so 0 and "no sequence" are the same bytes (sample.proto:53-70); a
subscriber-side tracker MUST treat a sample carrying sequence 0 as
unattributable and MUST NOT count it toward any gap figure (crates/runtime/node/src/gap.rs:176-178).
A node MUST NOT supply its own sequence number. The number is taken below the
node handle so that loss detection does not depend on every node author
getting it right (crates/runtime/node/src/lib.rs:52-59).
2.2 What a sequence number is attributed to
The counter belongs to the stream, not to the name and not to the node.
Two streams publishing under one name each count from 1 and do not share a
counter (crates/runtime/node/src/lib.rs:236 — the counter lives on StreamState; crates/runtime/node/src/lib.rs:651-657).
A subscriber-side implementation MUST key its sequence tracking on the pair
(connection, stream_ref) (crates/runtime/node/src/gap.rs:166,
tracks: HashMap<(Source, u32), Track>). Keying on stream_ref alone is
wrong because references are allocated per runtime and two publishers both
have a stream 1 (crates/runtime/node/src/lib.rs:158-170, crates/runtime/node/src/transport.rs:199-202). Keying on the name
is wrong for the reason in §2.4.
The connection identifier is not on the wire. Rust's Source names a
connection the receiving process holds and is meaningless to anyone else
(crates/runtime/node/src/gap.rs:70-71). A binding MUST therefore derive its own per-connection
identifier locally; it MUST NOT expect one to arrive in a message.
2.3 Ordering
A publisher-side implementation MUST NOT allow two samples from one stream to
reach a subscriber in an order other than their sequence order. Numbering and
delivery MUST be one indivisible step: an atomic counter alone gives every
sample a distinct number and still lets sample 2 be delivered before sample 1,
at which point a subscriber reports a loss that never happened
(crates/runtime/node/src/lib.rs:218-236). Rust holds the stream's counter lock across delivery
(crates/runtime/node/src/lib.rs:918 to the end of publish_with_source, delivery at
crates/runtime/node/src/lib.rs:983-985); the first version of the crate used an atomic and
tests/delivery.rs failed against it within a few hundred samples
(crates/runtime/node/src/lib.rs:226-229, test concurrent_publishes_arrive_in_sequence_order).
Publishes on different streams MUST NOT be serialised against each other
(crates/runtime/node/src/lib.rs:231-235).
2.4 Worked example: two publishers, one name
From crates/runtime/node/tests/delivery.rs:454-470 and crates/runtime/node/src/gap.rs:408-420. A subscriber to
trial.events; a stimulus node and a buttons node each declare a stream
under that name and publish alternately, three times each.
| Arrival | stream_ref | sequence |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 2 | 1 |
| 3 | 1 | 2 |
| 4 | 2 | 2 |
| 5 | 1 | 3 |
| 6 | 2 | 3 |
Keyed on (connection, stream_ref), the subscriber reports:
missing 0 breaks 0 duplicated 0 reordered 0 streams 2
Read as one series, the same six samples are 1, 1, 2, 2, 3, 3 — three
duplicates and no forward progress. A binding that reports that has reported
a broken session that is not broken, and nothing in the data says otherwise.
Now put the two publishers in different processes. Both runtimes allocate from
1, so both streams are stream_ref 1 (crates/runtime/node/src/lib.rs:199). Keyed on stream_ref
alone the two counts merge and the interleaving reads as duplicates again;
keyed on (connection, stream_ref) both runs are clean and streams is 2
(crates/runtime/node/src/gap.rs:423-437).
2.5 Streams that carry somebody else's samples
A stream that republishes samples numbered and stamped elsewhere — a
recording being replayed, a bridge from another framework — MUST pass the
sequence number through untouched (crates/runtime/node/src/lib.rs:1060-1063, Adopted::republish).
Renumbering 1, 2, 3, 7 as 1, 2, 3, 4 erases three lost samples and nothing
downstream can tell it happened (crates/runtime/node/src/lib.rs:715-718).
stream_ref is the one field such a stream MUST overwrite, with the reference
this runtime allocated to it (crates/runtime/node/src/lib.rs:1061). It is a label rather than a
measurement, and a sample whose reference disagreed with its own announcement
could not be attributed to it (crates/runtime/node/src/lib.rs:1028-1034).
Such a stream MUST be given its own connection identifier, distinct from local
publishing, or a replayed stream 1 and a live stream 1 merge
(crates/runtime/node/src/lib.rs:734-741; test an_adopted_stream_and_a_live_one_under_one_name_do_not_merge_their_counts).
A count of samples sent is not the last sequence number. For a producing
stream the two coincide (crates/runtime/node/src/lib.rs:989-995). For a republishing stream they
differ by exactly what was lost before the recording was made: 1, 2, 3, 7 is
four samples sent and a last sequence of 7 (crates/runtime/node/src/lib.rs:1048-1052,
crates/runtime/node/src/lib.rs:1088-1099; test the_published_count_is_samples_sent_rather_than_the_last_sequence).
An implementation that reports both MUST NOT report one as the other.
2.6 References
A runtime MUST allocate stream_ref from 1 (crates/runtime/node/src/lib.rs:199) and MUST NOT reuse
one, including after the stream that held it is dropped (crates/runtime/node/src/lib.rs:163-169;
test a_reference_is_never_reused_after_its_stream_is_dropped). A reference
that came back would make a subscriber's records of the old stream and the new
one merge into one apparently continuous count — a sequence going backwards,
read as an enormous loss or an enormous duplicate run.
stream_ref 0 means absent and MUST NOT be assigned (sample.proto:155-158,
stream.proto:229-231).
Rust allocates from one per-runtime counter shared between produced and
adopted streams (crates/runtime/node/src/lib.rs:760-767). An implementation MAY allocate differently
so long as references are unique within the runtime and never reused.
3. Gaps
3.1 Where a gap is detected
A subscriber-side implementation MUST read sequence numbers on arrival, before
its loss policy runs (crates/runtime/node/src/queue.rs:263-268; the call sits above the
depth check at crates/runtime/node/src/queue.rs:270). Reading them after the queue counts your own
drops as end-to-end loss and destroys the distinction in §3.5
(crates/runtime/node/src/queue.rs:216-221).
3.2 The first sample from a stream
The first sample seen for a (connection, stream_ref) pair MUST establish the
high-water mark and MUST NOT produce any missing count (crates/runtime/node/src/gap.rs:180-194). A
subscriber that starts at 5000 has missed nothing; it began late
(crates/runtime/node/src/gap.rs:181-183, test a_stream_joined_part_way_through_declares_nothing_lost).
3.3 Counting a gap
When an arriving sequence exceeds the high-water mark by step:
missingMUST increase bystep - 1(crates/runtime/node/src/gap.rs:196-200).- When
step - 1 > 0,breaksMUST increase by exactly 1 (crates/runtime/node/src/gap.rs:201). - The break MUST record the sequence before it, the sequence after it, how
many are missing, and both publisher publish timestamps — the one
bounding the gap below and the one bounding it above (
crates/runtime/node/src/gap.rs:88-99,crates/runtime/node/src/gap.rs:202-208). This is decision0009§1's "which stream, how many samples, between which timestamps" in full.
Both timestamps in a break are monotonic_timestamp_ns values from the
publishing stream (crates/runtime/node/src/queue.rs:263-268 passes sample.monotonic_timestamp_ns),
so they are comparable with each other and with nothing outside that stream's
clock domain (§4.1).
missing and breaks are different questions and MUST both be reported. One
stream losing 100 consecutive samples and one losing 100 scattered singles
have the same missing and are different problems: the first is a
disconnection, the second is a subscriber marginally too slow
(crates/runtime/node/src/gap.rs:110-114).
An implementation MUST retain at least the most recent break. It MAY retain
more; Rust keeps one, on the grounds that a complete per-gap log is bounded by
the loss rather than by the session and belongs in the recording
(crates/runtime/node/src/gap.rs:129-135).
3.4 Late arrivals, duplicates, and the reorder window
For a sample at or behind the high-water mark, let distance = highest - sequence.
- If
distance >= 64, the implementation MUST count it as reordered and MUST NOT call it a duplicate (crates/runtime/node/src/gap.rs:224-230). There is no retained history to check it against, and claiming a duplicate would point at a bug that does not exist. - Otherwise, if that position has already been seen, it MUST be counted as
duplicated (
crates/runtime/node/src/gap.rs:232-235). - Otherwise it MUST be counted as reordered, and
missingMUST be decremented by 1, saturating at zero (crates/runtime/node/src/gap.rs:237-242). It was counted missing when the gap opened and it is not missing.
breaks is never credited back. After 1, 2, 4 then a late 3, the correct
report is missing 0, breaks 1, reordered 1, duplicated 0
(crates/runtime/node/src/gap.rs:328-344).
The window width MUST be 64. It is not a wire property and nothing
transmits it, but it changes the numbers an implementation reports for
identical input, which is exactly the divergence this document exists to stop.
1, 200 then a late 2 gives missing 198, reordered 1 under a window of
64, because a distance of 198 is unreachable; a window wide enough to hold
that distance would credit the sample back and report missing 197. Rust
pins the constant with an assertion rather than leaving it to be inferred
(crates/runtime/node/src/gap.rs:62, crates/runtime/node/src/gap.rs:377-379: one u64 of history, checked as
1u64 << distance, which has nowhere to go past bit 63).
Nothing in Ranvier today produces a reordering at all: TCP does not reorder
and in-process delivery is ordered under one lock (crates/runtime/node/src/gap.rs:57-61). The window
exists because reading a reordering as a loss is the failure that would make
these numbers untrustworthy, and a UDP transport is a plausible addition.
A duplicated count above zero means a bug rather than a condition to handle
(crates/runtime/node/src/gap.rs:117-120).
3.5 Loss at your queue is a different number from loss before it
This is the distinction most easily lost in a reimplementation, and the distinction the counters exist for.
| Counter | What it means | Where |
|---|---|---|
dropped | This subscription's queue discarded a sample, because it was full | crates/runtime/node/src/queue.rs:274, crates/runtime/node/src/queue.rs:280 |
missing | A sample never reached this queue at all | crates/runtime/node/src/gap.rs:196-200 |
A sample discarded by your own queue MUST NOT raise missing. It was observed
before the policy ran (§3.1), so the sequence tracker has already seen it and
the series has no hole in it. Verified: with a depth of 4 and ten samples
published, dropped is 6 and gaps are clean
(crates/runtime/node/tests/delivery.rs:403-434, test loss_before_delivery_is_counted_apart_from_loss_at_the_queue).
missing means "lost before this queue", not "lost on the network". A
publisher's own transport thread holds an ordinary subscription with the
default options — depth 1024, drop-oldest (crates/runtime/node/src/lib.rs:366-368 calls subscribe,
which takes Options::default() at crates/runtime/node/src/lib.rs:809-815; the defaults are at
crates/runtime/node/src/queue.rs:93-99). No binding chooses that depth and none can: the C ABI's
ranvier_node_subscribe takes a node and a name and nothing else
(crates/abi/src/data.rs:565-583), so a dropped figure that differs between two
languages is a difference in how fast each drains one queue, not a difference in
how deep the queue was. If that queue overflows, the remote subscriber sees a hole
in the sequence numbers and counts it as missing, indistinguishable from a
loss on the wire. The publisher-side process records the same loss as
dropped on the transport node's subscription. An implementation MUST NOT
report missing as evidence of a network fault.
The claim in crates/runtime/node/src/transport.rs:32
crates/runtime/node/src/transport.rs:29-32 states that a sample crosses unchanged, "and it means a
gap caused by the network is as visible as a gap caused by a full queue."
The first half holds; the second does not, in the sense a reader would act
on. Nothing renumbers in transit — the transport sends the sample as stamped
(crates/runtime/node/src/transport.rs:264-274) and the receiving runtime delivers it without
renumbering or re-stamping (crates/runtime/node/src/lib.rs:351-357) — so a network loss does survive
as a hole in the sequence. But the two losses are not reported alike:
- A network loss produces a
Break: which stream, how many samples, and the two publisher timestamps bounding the hole (crates/runtime/node/src/gap.rs:88-99). - A queue drop produces a scalar per subscription (
crates/runtime/node/src/queue.rs:274,280). It carries no stream attribution — a subscription may carry several streams anddroppedis not keyed bystream_ref— no sequence range, and no timestamps. - Under drop-newest the loss is at the tail of what was queued, so the
delivered sequence numbers have no hole in them at all
(
crates/runtime/node/tests/delivery.rs:101-123: ten published,1, 2, 3, 4delivered).
Both are counted, which is what decision 0009 §1 requires. They are not
equally visible, and an implementation that reports only one of the two
numbers has reported half of a loss.
3.6 What is not counted
- A sample whose
stream_refis 0, or whose sequence is 0, MUST be left untracked: it counts as having arrived, changes no gap counter, and does not raise the distinct-stream count (crates/runtime/node/src/gap.rs:176-178; testa_sample_that_names_no_stream_is_left_alone). Guessing which stream it belonged to produces exactly the false gaps this accounting exists to avoid. - Samples still queued when a subscription is dropped are discarded and are
not a loss anybody incurred (
crates/runtime/node/src/lib.rs:1104-1105).
3.6a An unreadable frame is counted, and it is the connection's number
A frame that arrived whole and carried nothing the receiver could read is a loss like any other, and decision 0009 makes counting every loss unconditional. Two cases, and they are the same disagreement seen from two distances:
- Bytes the protobuf decoder refused (
crates/runtime/node/src/transport.rs:582). - A
Framethat decoded and set neither variant of itspayloadoneof (crates/runtime/node/src/transport.rs:607) — what a publisher built against a contract with a third payload kind sends.
A subscriber-side implementation MUST skip both and MUST count both. Skipping is the only available action — nothing can be done with a frame that carries nothing — and closing the connection over one would turn a publisher's mistake into an outage. Counting is what stops the failure being invisible: without it, a publisher and a subscriber disagreeing about the contract looks from the subscribing end exactly like a quiet stream, with no samples, no gaps and no error.
The count is per connection, not per subscription
(crates/runtime/node/src/transport.rs:163). A frame that failed to decode named no stream, so
there is no subscription to attribute it to; attributing it to the name the
connection asked for would be a guess, and one that is wrong exactly when a
publisher serves several streams down one socket. An implementation whose
transport is one socket per subscription MAY report it per subscription, and
the two are then the same number.
A frame that misaligns the byte stream is a different failure and MUST NOT be
counted here. Every byte after it is suspect, so the connection closes
(crates/runtime/node/src/transport.rs:613-617); that loss is visible as the connection ending.
Verified: two frames sent by a deliberately misbehaving publisher, one of each
kind, are counted as two with nothing delivered
(crates/runtime/node/tests/transport.rs:773, test
a_frame_that_carries_nothing_readable_is_counted_rather_than_passed_over).
3.7 What a subscriber is told
An implementation SHOULD report, per subscription: missing, breaks,
duplicated, reordered, the number of distinct streams that have fed the
subscription, and the most recent break (crates/runtime/node/src/gap.rs:101-136).
missing is what is currently believed missing, not a high-water figure:
it is credited back when a late sample turns up (crates/runtime/node/src/gap.rs:106-108). An
implementation MUST NOT present it as a monotonic total.
The distinct-stream count is worth reporting because it explains why the
sequence numbers a person sees in a log do not run consecutively
(crates/runtime/node/src/gap.rs:122-128). It counts tracked (connection, stream_ref) pairs
(crates/runtime/node/src/gap.rs:192).
3.8 Worked example: gap counting
Each row names the test it comes from, and all of those tests passed on 2026-08-13. Where a test asserts only some of the columns, the remainder follow from §3.3 and §3.4.
| Sequences observed, one stream | missing | breaks | duplicated | reordered | Source |
|---|---|---|---|---|---|
1, 2, 3, 4, 5 | 0 | 0 | 0 | 0 | crates/runtime/node/src/gap.rs:266-273 |
1, 2, 4 | 1 | 1 | 0 | 0 | crates/runtime/node/src/gap.rs:276-295 |
1, 500 | 498 | 1 | 0 | 0 | crates/runtime/node/src/gap.rs:297-306 |
1, 3, 5, 7 | 3 | 3 | 0 | 0 | crates/runtime/node/src/gap.rs:308-315 |
5000, 5001, 5002 | 0 | 0 | 0 | 0 | crates/runtime/node/src/gap.rs:317-326 |
1, 2, 4 then 3 | 0 | 1 | 0 | 1 | crates/runtime/node/src/gap.rs:328-344 |
1, 2, 3, 2 | 0 | 0 | 1 | 0 | crates/runtime/node/src/gap.rs:346-357 |
1, 200 then 2 | 198 | 1 | 0 | 1 | crates/runtime/node/src/gap.rs:359-405 |
For 1, 2, 4 the break itself is
{ after: 2, before: 4, missing: 1, after_ns: 2, before_ns: 4 } — the two
_ns values being the publish timestamps of the samples on either side of the
hole (crates/runtime/node/src/gap.rs:285-294).
Two streams, one of which loses samples (crates/runtime/node/src/gap.rs:451-462): stream 1
delivers 1, 2, 3; stream 2 delivers 1 then 9. The subscription reports
missing 7, breaks 1, and a last break whose before is 9. Stream 1
contributes nothing to either figure. A tracker that pooled the two streams
would attribute the hole to whichever sample happened to arrive next.
3.9 Worked example: a full queue against a lossy link
Depth 4, ten samples published by one stream. The first two rows are asserted
by crates/runtime/node/tests/delivery.rs:75-97 and crates/runtime/node/tests/delivery.rs:101-123; the third applies §3.3 to a
receiver that sees 1, 2, 3, 4, 10 and is derived from the rules above rather
than read from a test.
| Readable by the subscriber | delivered | dropped | missing | breaks | |
|---|---|---|---|---|---|
| Queue full, drop-oldest | 7, 8, 9, 10 | 10 | 6 | 0 | 0 |
| Queue full, drop-newest | 1, 2, 3, 4 | 4 | 6 | 0 | 0 |
| Six lost upstream | 1, 2, 3, 4, 10 | 5 | 0 | 5 | 1 |
The publisher reports 10 published in all three rows; publishing never failed
(crates/runtime/node/tests/delivery.rs:97).
delivered counts samples placed in the queue, not samples the subscriber
received (crates/runtime/node/src/queue.rs:287). Under drop-oldest a placed sample can be evicted
later, so what remains readable is delivered - dropped; under drop-newest a
discarded sample was never placed, so what remains readable is delivered.
An implementation MUST NOT present delivered as a count of what a subscriber
read.
4. Timestamps
Every timestamp on a sample and in an announcement is in nanoseconds, and
every one on the wire is fixed64 so that a reader never has to ask which
encoding a particular one uses (sample.proto:43-50).
4.1 monotonic_timestamp_ns — required
Present on every sample. It says when the publishing node handed the
observation to Ranvier (sample.proto:34-51).
- The publisher-side implementation MUST stamp it below the node handle, from
the host clock, at publish time (
crates/runtime/node/src/lib.rs:937,crates/runtime/node/src/lib.rs:942). A node MUST NOT supply it: a timestamp a node supplied could come from any clock at all (crates/runtime/node/src/lib.rs:57-59). - The clock MUST be system-wide — comparable between two processes on one
machine — and MUST include time the machine spent asleep
(
crates/runtime/clock/src/lib.rs:8-37). The platform call differs per platform because the platforms disagree about which of their clocks does what:CLOCK_BOOTTIMEon Linux,CLOCK_MONOTONIC_RAWon macOS,QueryPerformanceCounteron Windows (crates/runtime/clock/src/lib.rs:30-37).std::time::Instantand its equivalents in other languages satisfy neither property and MUST NOT be used (crates/runtime/clock/src/lib.rs:14-18). - The epoch is arbitrary and carries no meaning. Only differences do
(
crates/runtime/clock/src/lib.rs:39-43). Readings are not comparable across hosts or across reboots of one host. - A consumer MUST NOT subtract two readings unless the streams that produced
them announced the same
(host_id, boot_id)pair (stream.proto:253-267,crates/runtime/node/src/announce.rs:462-464). Both halves or neither: a host without a boot identifies a machine and not a clock (crates/runtime/node/src/announce.rs:545-550). - A republishing stream MUST pass it through untouched
(
crates/runtime/node/src/lib.rs:1060-1063; testa_republished_sample_keeps_its_sequence_and_both_timestamps) and its announcement SHOULD state the host and boot the timestamps belong to rather than the replaying machine's (crates/runtime/node/src/announce.rs:249-266,crates/runtime/node/src/announce.rs:309-312). See §9 for what happens when it does not.
4.2 source_timestamp_ns — optional
When the instrument itself measured the observation, on the instrument's own
clock (sample.proto:98-138).
0means absent, and absent is ordinary. An analysis stage or a stimulus program has no upstream clock (sample.proto:121-125). A publisher with no source timestamp MUST publish0(crates/runtime/node/src/lib.rs:890-892).- It MUST be passed through untouched: never rebased onto the host clock,
never pre-corrected by the publishing node (
crates/runtime/node/src/lib.rs:896-900). Mapping it onto the host timeline belongs to the consumer, from parameters estimated while data is being collected, so a bad estimate can be corrected afterwards instead of being baked in. - A stream that announced no source clock domain MUST NOT carry one. A
timestamp supplied to such a stream MUST be replaced with
0rather than sent (crates/runtime/node/src/lib.rs:909-914; testa_stream_that_named_no_clock_cannot_carry_a_source_timestamp). A reading nobody can name is a number that looks like time, sorts like time, and cannot be placed on any timeline; a consumer cannot tell it apart from one that can, because absence and an unnamed domain are the same empty string on the wire (crates/runtime/node/src/lib.rs:851-860). - A publisher-side implementation MUST expose whether a source timestamp will
be carried, so a node that intends to carry instrument time discovers a
missing declaration rather than a missing timestamp (
crates/runtime/node/src/lib.rs:846-871). - A refusal MUST NOT propagate to the caller as an error. Decision
0009makes it unconditional that an acquisition loop is never blocked or told no, so the only thing a publisher could do with a refusal is discard the whole sample (crates/runtime/node/src/lib.rs:860-864).
4.3 The clock domain
source_clock_domain names the clock that a stream's source timestamps are
expressed in. It is producer-defined and conventionally device.<serial>
(crates/runtime/node/src/announce.rs:225-231). It SHOULD be set for any stream that publishes source
timestamps, and an empty value MUST be read as "this stream carries no source
timestamps" (stream.proto:243-251, crates/runtime/node/src/announce.rs:543).
Two streams whose stamps come from different domains MUST NOT be subtracted
(crates/runtime/node/src/announce.rs:416-418).
4.4 Timestamps that are not on the host clock
Authorisation.valid_from_unix_ns and valid_until_unix_ns are wall clock,
nanoseconds since the Unix epoch — an authorisation is a calendar fact about
a person and a protocol, not an interval within a session. 0 means unstated,
which is not the same as open-ended (crates/runtime/node/src/announce.rs:141-149,
stream.proto:101-110).
4.5 Latency, and what it measures
An implementation MAY report delivery latency. Rust does, and the definition matters because two definitions produce different numbers:
The age of a sample when its subscriber takes it — the host clock read on
the consumer's thread at the moment the sample is handed over, minus the
sample's monotonic_timestamp_ns, saturating at zero (crates/runtime/node/src/queue.rs:397-406).
That includes queue wait, which is the point: a subscriber acting on data
200 ms old is acting on data 200 ms old whatever the machinery underneath cost
(crates/runtime/node/src/latency.rs:4-8).
Rust reports p50, p99, and the maximum over a sliding window of 256 recent
deliveries, by nearest rank with index = round((n - 1) * fraction)
(crates/runtime/node/src/latency.rs:31, crates/runtime/node/src/latency.rs:65-84, pinned by crates/runtime/node/src/latency.rs:118-132). Those
are implementation choices and MAY differ. What MUST NOT differ is the
subtraction: a figure measured on the publisher's thread, or against the
source timestamp, is a different quantity and MUST NOT be reported under the
same name.
Percentiles MUST be absent rather than zero until something has been
delivered: a subscriber that has received nothing has no latency, which is
different from a latency of zero (crates/runtime/node/src/latency.rs:65-68).
This figure is not defined for a republished stream. The subtraction crosses clock domains — the host clock now, minus a timestamp another machine took at another time — and the runtime computes it anyway, without qualifying it. See §10.
5. Loss policy
5.1 A publisher is never made to wait
Publishing MUST NOT block and MUST NOT fail (crates/runtime/node/src/queue.rs:249-252;
crates/runtime/node/src/lib.rs:878-889). Decision 0009 §1 makes this a correctness property rather
than a preference: blocking a source node does not prevent data loss, it
relocates the loss into the instrument's own buffer, where nothing can count
it and it has no sequence number.
The Rust surface makes this structural rather than promised — publish
returns nothing, so there is no signature by which a publisher could be told
to wait (crates/runtime/node/src/lib.rs:878-887). An implementation SHOULD make it structural the
same way. A publisher-side API that returns a result a caller can only
unwrap invites the blocking it is meant to prevent.
The consequence a subscriber MUST accept: a full queue is not an error, and the sample that was discarded is accounted for at the subscriber (§3.5) rather than reported to the publisher.
A blocking read on a subscription cannot deadlock against a publisher, because
there is no cycle to form (crates/runtime/node/src/queue.rs:304-306, crates/runtime/node/src/lib.rs:1169-1171).
5.2 The policies
| Policy | Behaviour | Status |
|---|---|---|
drop-oldest | Overwrite the oldest unread sample. The default | Implemented (crates/runtime/node/src/queue.rs:41-42, crates/runtime/node/src/queue.rs:272-275) |
drop-newest | Discard the arriving sample, keep what is queued | Implemented (crates/runtime/node/src/queue.rs:47-48, crates/runtime/node/src/queue.rs:279-282) |
block | Wait for space | Not implemented (crates/runtime/node/src/queue.rs:50-56) |
halt | End the session cleanly, recording the stream and the deficit | Not implemented (crates/runtime/node/src/queue.rs:58-62) |
An implementation MUST refuse a policy it does not implement rather than
substituting one it does (crates/runtime/node/src/queue.rs:135-139, OptionsError::PolicyNotImplemented).
A policy that does something other than what it says is the specific failure
decision 0009 spends a paragraph on.
A queue depth of 0 MUST be refused: nothing could ever be delivered
(crates/runtime/node/src/queue.rs:132-134).
5.3 Depth
The default depth is 1024 and is recorded as arbitrary — nothing here has
been measured (crates/runtime/node/src/queue.rs:76-87). Too shallow and ordinary scheduling jitter
costs data: a subscriber descheduled for 10 ms at 1 kHz needs ten slots merely
to survive it. Too deep and a stalled subscriber pins memory a headset may not
have.
An implementation MAY choose a different default. A depth expressed in
milliseconds would adapt between a 30 Hz camera and a 30 kHz amplifier; it
needs a declared sample rate, which no announcement carries, and some streams
have no rate at all (crates/runtime/node/src/queue.rs:82-87).
5.4 What MUST be counted
- Every discarded sample MUST increment
dropped, exactly once (crates/runtime/node/src/queue.rs:274,crates/runtime/node/src/queue.rs:280). Decision0009§1: every loss is counted, whatever policy is chosen. - An implementation SHOULD report the deepest the queue has ever been against
its configured depth (
crates/runtime/node/src/queue.rs:182-196, recorded after the push atcrates/runtime/node/src/queue.rs:291). Current occupancy answers the question a moment too late: a subscriber that fell behind during a burst and caught up reads as empty, so anyone who looks after it recovered sees a healthy subscription and no evidence it nearly lost anything. - An implementation SHOULD report how long it has been since a subscription
last received, and how long since a stream last published
(
crates/runtime/node/src/queue.rs:208-211,crates/runtime/node/src/lib.rs:246-254). A stream that has stopped looks exactly like one publishing slowly until something reports how long it has been.
6. Schema announcement
6.1 What is announced
Everything constant for a stream's life is announced once, and it is what
makes a sample interpretable: the values are opaque bytes and the source
timestamp is on a clock nobody named (crates/runtime/node/src/announce.rs:1-10).
An announcement carries (crates/runtime/node/src/announce.rs:313-350, stream.proto:214-286):
| Field | Required | Meaning |
|---|---|---|
name | Yes | The name it publishes under. A name MUST NOT be empty (crates/runtime/node/src/announce.rs:292-297) |
stream_ref | Yes, >= 1 | What its samples carry (§2.6) |
node | Yes | The node that declared it |
schema | No | schema_id and descriptor (§6.2) |
origin | No | raw, derived, ephemeral, or unspecified |
source_clock_domain | No | Which clock the source timestamps are on (§4.3) |
host_id, boot_id | Both or neither | Which clock the publish timestamps are on (§4.1) |
operator | No | Who the application says was operating, and how it knows |
authorisation | No | The authority the capture was made under |
production | No | What the producer did to the instrument's values |
Absent means unstated, never a claim. A consumer MUST NOT read silence as
a promise: an absent production says nobody was asked, not that the values
are untransformed (crates/runtime/node/src/announce.rs:469-474). origin unspecified means the
stream declined to say, and a reader MUST NOT assume (stream.proto:34-35).
origin changes no behaviour by itself. Decision 0009 §2 keeps it separate
from the loss policy deliberately: this says what the data is, the policy
says what to do when a subscriber cannot keep up, and different people choose
them for different reasons (crates/runtime/node/src/announce.rs:44-48).
6.2 The descriptor bytes
SchemaInfo.schema_id is the message's fully-qualified protobuf name, such as
ranvier.example.v1.PointerSample. It is identity, not interpretation: two
integrations producing the same kind of observation declare the same name,
which is what lets one analysis node consume either (crates/runtime/node/src/announce.rs:21-27).
SchemaInfo.descriptor is a serialized protobuf FileDescriptorSet
describing that message and its imports (crates/runtime/node/src/announce.rs:29-39,
stream.proto:123-137). It is what makes a stream readable by something never
compiled against it — a browser, a converter, a tool written years from now.
Measured at 229 bytes for this project's own Sample, sent once per stream
(stream.proto:131).
The core MUST NOT parse the descriptor. It carries the bytes to whoever
wants them, exactly as it carries a sample's values, and that is what lets a
schema be added without changing the core (crates/runtime/node/src/announce.rs:36-38).
6.3 When an announcement is sent
Decision 0040, implemented in serve_one:
- A connection MUST announce every stream publishing under the requested name
before it sends any sample (
crates/runtime/node/src/transport.rs:236-240,announce_unheard). Announcing the first one found leaves the subscriber holding samples from a stream it has no schema for, and holding an announcement that looks like the answer (crates/runtime/node/src/transport.rs:36-40). - The set of announced references MUST be kept per connection, not per
runtime: a subscriber that dialled a moment ago has heard nothing a
subscriber from an hour ago heard (
crates/runtime/node/src/transport.rs:231-234). - A connection MUST re-sweep the first time a
stream_refit has not heard of arrives, so a stream that starts publishing an hour into a session announces itself an hour in (crates/runtime/node/src/transport.rs:255-262). Nodes in a graph start in no order, so this is not an edge case. - The reference MUST be recorded whether or not the sweep found anything for
it. That is what keeps the cost at one sweep per stream rather than one per
sample (
crates/runtime/node/src/transport.rs:251-254). - Announcements and samples interleave on one connection; that is what the
frame wrapper is for (
0040§3).
An announcement MAY describe a stream that never publishes: the sweep
announces every stream under the name, whether or not it has sent anything
(0040, What it costs). A consumer counting announcements is not counting
active streams.
6.4 A stream does not re-announce
A StreamInfo MUST NOT change for the life of the stream it describes
(0040 §3). Every field in it is constant by construction; in Rust
StreamState.info is written once in Node::open (crates/runtime/node/src/lib.rs:769-779) and has
no mutating path:
grep -n "state.info" crates/runtime/node/src/lib.rs
returns reads only. A consumer MUST NOT wait for a re-announcement, and MUST NOT treat a second announcement carrying one stream's reference as an update.
An earlier version of the contract promised re-announcement on change. That
promise is withdrawn rather than implemented, because nothing in the runtime
can change a StreamInfo (0040 §3). If you are porting from a document that
still says otherwise, the document is wrong.
6.5 A sample whose reference has no announcement
This MAY happen and a consumer MUST handle it. The publisher's registry holds
streams weakly, so a stream withdrawn between publishing a sample and the
sweep leaves nothing to announce; the sample is sent rather than discarded,
because discarding it would be an uncounted loss (crates/runtime/node/src/transport.rs:46-52,
0040 §5).
- A consumer MUST report the schema as unknown.
- A consumer MUST NOT fall back on another announcement under the same name
(
0040§5). That is the misread the announcement rule exists to prevent, arrived at from the other direction. - A recorder MUST NOT write such a sample, and MUST NOT write it under another
publisher's stream (
0041§3).
6.6 Reading an announcement
- An empty string MUST be read as absent, not as an empty value. The wire has
no optionality for a string, so a stream that named no clock domain and one
that named the empty string are the same bytes (
crates/runtime/node/src/announce.rs:511-524). - A schema with neither an identity nor a descriptor MUST be read as no schema
(
crates/runtime/node/src/announce.rs:531-541). host_idandboot_idMUST be read as a pair: both, or neither (crates/runtime/node/src/announce.rs:545-550).- An unrecognised
originor assertion-method value MUST be read as unspecified rather than refused. A publisher built against a later contract may name a kind this build has never heard of, and refusing the whole announcement over it would lose a stream that is otherwise readable (crates/runtime/node/src/announce.rs:479-492,crates/runtime/node/src/announce.rs:496-506).
6.7 Matching announcements to samples
A subscriber to a name MUST be able to read every publisher's
announcement, not one of them (crates/runtime/node/src/lib.rs:1120-1128). Returning a single one
silently picks whichever announced most recently.
Match announcements to samples by stream_ref, never by name
(crates/runtime/node/src/lib.rs:1130-1137). A consumer that took one announcement as the answer for
a name reads half its data against another publisher's schema and counts two
sequence series as one. A recorder MUST write one stream per publisher, keyed
by the reference (0041 §1); one that guessed wrote a single stream carrying
two publishers' interleaved sequence numbers against one of their two schemas
(0041, Context).
Two streams one node publishes under one name — one per trial block, one per
camera — are two streams, and a receiver MUST NOT deduplicate them by
(name, node) (0041 §6, crates/runtime/node/src/lib.rs:398-413).
An empty announcement list before any publisher has announced is the ordinary
state at startup, not an error: subscribing does not wait for a publisher
(crates/runtime/node/src/lib.rs:1140-1141, crates/runtime/node/src/lib.rs:801-803).
7. Immutability
Decision 0019: a delivered sample is immutable, and subscribers receive a
shared reference to one sample rather than a copy each. In Rust that is
Arc<Sample> throughout the delivery path (crates/runtime/node/src/queue.rs:200, crates/runtime/node/src/lib.rs:983-985).
- A subscriber MUST NOT modify a sample it receives. A sample is a record of
something that already happened; nothing downstream has any business
changing it (
0019, Why). - A transform node MUST NOT modify a sample in place. It produces a new
sample on a derived stream — which is what the provenance model already says
happens (
0019, Why). - Two subscribers to one name hold the same bytes. An implementation MUST NOT let one subscriber's handling of a sample be observable by another.
- Whether the implementation shares or copies is its own choice and MUST NOT
be exposed in a graph (
0019, Decision). A binding MAY copy where its language cannot express shared immutable ownership; the requirement is the observable behaviour, not the mechanism. - A subscriber holding a reference keeps the sample alive, so a slow
consumer pins the publisher's memory (
0019, What it costs). A binding SHOULD release samples as soon as its consumer is done with them. - Across a C ABI a shared sample becomes an opaque handle plus a release call
the caller must make, because lifetimes do not survive the boundary. Every
binding MUST hide that behind whatever its language does automatically; a
binding that forgets leaks (
0019, What it costs).
8. Where the runtime enforces nothing
Each of these is a hole rather than a rule. They are listed so that a binding author does not read the absence of an error as permission.
A stream may name a clock domain and never publish a source timestamp, and
nothing says so. stream.proto:247-250 calls that stream malformed, and the
error case exists — SpecError::ClockDomainWithoutTimestamps
(crates/runtime/node/src/announce.rs:354-363) — but nothing constructs it:
grep -rn "ClockDomainWithoutTimestamps" crates/runtime/
returns the declaration and its display text only. crates/runtime/node/src/announce.rs:286-296 says
the condition is reported by the runtime rather than refused, on the grounds
that it is knowable on the first sample. Nothing reports it. Do not build a
binding that relies on being told.
A republishing stream is not gated on its clock domain. publish_with_source
zeroes a source timestamp from a stream that named no clock (§4.2,
crates/runtime/node/src/lib.rs:909-914). Adopted::republish applies no such gate
(crates/runtime/node/src/lib.rs:1039-1063): a replayed sample carries its recorded source timestamp
whether or not the adopting spec named a domain. A caller replaying a
recording SHOULD declare the recorded stream's clock domain; nothing checks
that it did.
A republishing stream is not gated on its publishing host either. The spec
should state the host and boot the timestamps belong to
(crates/runtime/node/src/announce.rs:249-266); if it does not, the runtime announces its own
(crates/runtime/node/src/announce.rs:309-312), and a consumer is told it may subtract two readings
that never shared an origin.
Latency is computed for republished samples across clock domains. The age
at delivery is now - monotonic_timestamp_ns with no check that the two
readings share a domain (crates/runtime/node/src/queue.rs:400-404). For a replayed stream that
subtraction has no meaning, and the saturating subtraction turns a
recorded-in-the-future stamp into zero rather than into an error.
A graph binding cannot choose a depth or a policy. Decision 0009 §3 says
loss policy is chosen per connection in the graph. Bound::bind subscribes
with the defaults (crates/runtime/node/src/ports.rs:389-391, calling Node::subscribe), so every
input port bound today gets depth 1024 and drop-oldest. crates/runtime/node/src/queue.rs:65-71
records this as expected — the properties belong on a connection, and there is
no graph yet.
block is refused at subscribe time, not at graph load. Decision 0009
§3 places the refusal at graph load. The runtime refuses it wherever options
are validated (crates/runtime/node/src/queue.rs:131-139). The effect is the same today; a graph
implementation MUST keep the refusal rather than assume the runtime's is the
only one.
stream_ref collides across sources, and a runtime that both adopts and
serves can announce the wrong stream for a reference. serve_one sweeps by
name and matches by reference (crates/runtime/node/src/transport.rs:295-302), so two adopted streams
from different publishers, both numbered 1, are indistinguishable to it.
Nothing builds that today; it is reachable by hand, by calling serve on a
runtime that also holds a Link (0040, What it costs).
9. Comments in the contract that are out of date
Where a comment and the code disagree, the code is what runs. These are recorded so a reimplementation built by reading the prose does not inherit them.
crates/runtime/node/src/transport.rs:32 — "a gap caused by the network is as visible as a gap
caused by a full queue." True for the renumbering claim it rests on, not
true as a statement about reporting. See §3.5 for the full accounting, and
crates/runtime/node/tests/delivery.rs:403-434 for the case that settles it.
sample.proto:114-119 — "The announcement does not exist yet; it arrives
with the control plane." The announcement exists and crosses on the data
plane, as StreamInfo (crates/runtime/node/src/transport.rs:304-313, decision 0040 §1). Decision
0036 §2 keeps it there deliberately, so that the descriptor is not carried
in two places that can disagree. The same paragraph's substantive rule — which
clock a source timestamp is on lives on the announcement and not on the sample
— is correct and is specified in §4.3.
Decision 0009 §3's placement of policy in the graph, and the withdrawn
promise of re-announcement, are covered in §8 and §6.4 respectively.
10. Not yet specified
Stated as open rather than filled in with a rule nobody made.
- What a consumer does with an unattributable sample, beyond not guessing. §6.5 requires reporting the schema unknown; the shape of that report is not specified.
- Queue depth in units of time. A depth in milliseconds would adapt
between a 30 Hz camera and a 30 kHz amplifier. It needs a declared sample
rate, which no announcement carries, and some streams have no rate at all —
a button box emits when pressed (
crates/runtime/node/src/queue.rs:82-87). blockandhalt. Named by decision0009§3 and refused by the implementation (crates/runtime/node/src/queue.rs:50-62).haltadditionally needs a threshold distinguishing a sustained rate deficit from a long transient stall, which nobody has measured (0009, What it costs).- Latency for a stream whose samples were stamped on another clock. §4.5. The runtime produces a number; this document does not say what it means.
- Percentile method and window size. Rust uses nearest rank over 256
recent deliveries (
crates/runtime/node/src/latency.rs:31,crates/runtime/node/src/latency.rs:73-76). Nothing makes those a contract, and two implementations reporting p99 over different windows are reporting different quantities. - How a subscriber learns a subscription has permanently stopped. Rust
reports a subscription whose lock was poisoned by a panicking thread
(
crates/runtime/node/src/queue.rs:344-352,crates/runtime/node/src/lib.rs:606-612); poisoning is a Rust concept and the general condition — "this subscription will receive nothing further, and it is not merely quiet" — has no specified representation. - Reference behaviour through a relay. Two upstream publishers whose
references collide would arrive on one connection with two announcements
claiming one reference. The relay is not built, and its record has to answer
this (
0040, What would change this;0041, What it costs). - What a second announcement for one stream means. §6.4 forbids
re-announcement. An operator identity that can be re-asserted mid-session
would change that, and the recording format has to define what a second
stream record for one reference means before it could be built
(
0040§3,0041, What would change this). - A typed input port bound to a name whose publishers announce different
schemas. Port checking compares only the first publisher's
(
0041, What would change this). - Uniqueness of node names. Nothing checks them, and refusing a duplicate
would enforce a rule no component depends on (
crates/runtime/node/src/lib.rs:296-300).
Nothing below is built, and each needs a decision before it can be.
- Batching. Whether several samples may share a message, how many and over what interval, whether those defaults vary with a declared rate, and how a caller forces a flush mid-batch. Nothing batches today.
- Mapping one clock domain onto another. The exchange, the estimator, the quality metric and its unit, whether timestamps are rewritten on the way through, and what a recording stores. §4.4 states only that a timestamp on a foreign clock is carried untouched.
- The epoch for an instrument-domain timestamp. Which instant
source_timestamp_nsis counted from is the instrument's business today, and two instruments in one recording may disagree with nothing to say so. - Conformance profiles over streams. Which profiles ship, what each asserts
about a stream, and the form the conformance record takes. See
conformance.mdfor what exists. - Carrying samples over a datagram transport. Whether it is permitted at all, and if so how the sequence accounting in §2 and §3 is met without ordered delivery.
Why these rules are the way they are
The reasoning behind the rules above is recorded in docs/decisions/, which is
history rather than specification — read it to understand a choice, not to learn
what the rule is.
| Record | What it argued |
|---|---|
0009 | Origin is declared, loss policy is chosen, and the two are independent. §3 and §5 rest on it |
0019 | A delivered sample is shared and immutable. §7 |
0040 | A connection announces every stream it delivers, and no stream re-announces. §6.3, §6.4, §6.5 |
0041 | A recorder writes one stream per publisher. §6.7 |