Skip to main content

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 livePath
sample.proto, stream.protoproto/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.

Arrivalstream_refsequence
111
221
312
422
513
623

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, 3three 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:

  • missing MUST increase by step - 1 (crates/runtime/node/src/gap.rs:196-200).
  • When step - 1 > 0, breaks MUST 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 decision 0009 §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 missing MUST 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.

CounterWhat it meansWhere
droppedThis subscription's queue discarded a sample, because it was fullcrates/runtime/node/src/queue.rs:274, crates/runtime/node/src/queue.rs:280
missingA sample never reached this queue at allcrates/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 and dropped is not keyed by stream_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, 4 delivered).

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_ref is 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; test a_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 Frame that decoded and set neither variant of its payload oneof (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 streammissingbreaksduplicatedreorderedSource
1, 2, 3, 4, 50000crates/runtime/node/src/gap.rs:266-273
1, 2, 41100crates/runtime/node/src/gap.rs:276-295
1, 500498100crates/runtime/node/src/gap.rs:297-306
1, 3, 5, 73300crates/runtime/node/src/gap.rs:308-315
5000, 5001, 50020000crates/runtime/node/src/gap.rs:317-326
1, 2, 4 then 30101crates/runtime/node/src/gap.rs:328-344
1, 2, 3, 20010crates/runtime/node/src/gap.rs:346-357
1, 200 then 2198101crates/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.

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 subscriberdelivereddroppedmissingbreaks
Queue full, drop-oldest7, 8, 9, 1010600
Queue full, drop-newest1, 2, 3, 44600
Six lost upstream1, 2, 3, 4, 105051

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_BOOTTIME on Linux, CLOCK_MONOTONIC_RAW on macOS, QueryPerformanceCounter on Windows (crates/runtime/clock/src/lib.rs:30-37). std::time::Instant and 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; test a_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).

  • 0 means 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 publish 0 (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 0 rather than sent (crates/runtime/node/src/lib.rs:909-914; test a_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 0009 makes 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

PolicyBehaviourStatus
drop-oldestOverwrite the oldest unread sample. The defaultImplemented (crates/runtime/node/src/queue.rs:41-42, crates/runtime/node/src/queue.rs:272-275)
drop-newestDiscard the arriving sample, keep what is queuedImplemented (crates/runtime/node/src/queue.rs:47-48, crates/runtime/node/src/queue.rs:279-282)
blockWait for spaceNot implemented (crates/runtime/node/src/queue.rs:50-56)
haltEnd the session cleanly, recording the stream and the deficitNot 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). Decision 0009 §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 at crates/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):

FieldRequiredMeaning
nameYesThe name it publishes under. A name MUST NOT be empty (crates/runtime/node/src/announce.rs:292-297)
stream_refYes, >= 1What its samples carry (§2.6)
nodeYesThe node that declared it
schemaNoschema_id and descriptor (§6.2)
originNoraw, derived, ephemeral, or unspecified
source_clock_domainNoWhich clock the source timestamps are on (§4.3)
host_id, boot_idBoth or neitherWhich clock the publish timestamps are on (§4.1)
operatorNoWho the application says was operating, and how it knows
authorisationNoThe authority the capture was made under
productionNoWhat 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_ref it 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_id and boot_id MUST be read as a pair: both, or neither (crates/runtime/node/src/announce.rs:545-550).
  • An unrecognised origin or 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).
  • block and halt. Named by decision 0009 §3 and refused by the implementation (crates/runtime/node/src/queue.rs:50-62). halt additionally 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_ns is 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.md for 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.

RecordWhat it argued
0009Origin is declared, loss policy is chosen, and the two are independent. §3 and §5 rest on it
0019A delivered sample is shared and immutable. §7
0040A connection announces every stream it delivers, and no stream re-announces. §6.3, §6.4, §6.5
0041A recorder writes one stream per publisher. §6.7