Reference 02 — Graph Model and Same-Substrate Insight¶
Status: draft, v1, 2026-05-03. Defines what a graph IS in libtracer and the load-bearing claim that the in-memory graph and the wire bytes are the same substrate. Audience: implementers writing the router/dispatcher; anyone reasoning about zero-copy semantics. See also: 04-communication-flows.md for API rationale; 01-data-format.md for the byte layout this section interprets structurally.
Definitions¶
Vertex (also called endpoint, point, node-in-graph): a named, addressable position in the graph at which data can be written, read, or subscribed to. A vertex has a path, an optional schema, zero or more subscribers, and an optional last-known-value.
Edge: a subscription. Concretely, a SUBSCRIBER TLV stored in some vertex’s
:subscribers[N]slot, naming a target path that should receive a copy of every future write.Path: an ordered list of UTF-8 NAME segments rooted at
/, addressing a vertex (or a field on a vertex). Syntax in 03-addressing.md.Schema: a structured TLV (typically a
POINTor aSETTINGS-shaped record) returned at<vertex>:schema, enumerating the writable fields the vertex exposes. Includes the core fields (:subscribers[],:settings.*,:liveness.*,:acl) plus any module-namespaced fields. Read-only.Forwarder (router): the stateless component (
fwd_router_t) that routesFWDframes between a node’s local graph and its named transport links — each hop strips the leadingdstsegment and growssrcwith the way back. To a downstream subscriber a forwarded delivery is indistinguishable from a local write.Buffer segment: a refcounted region of real memory backing one or more views. The unit of ownership.
View: a
{owner_segment, offset, length}triple naming a contiguous span of bytes inside a buffer segment. Views never own bytes; segments do. A view holds a refcount on its owner segment.Same-substrate: the architectural claim that a TLV in memory IS a graph node IS the wire bytes. The in-memory graph is a tree of views; serialization is a walk of the tree; deserialization is constructing the tree of views over the received bytes.
The six-layer model¶
The protocol stack has six distinct layers of concern, numbered bottom-up from memory at L0 to application semantics at L5. Concepts in this reference suite belong to exactly one layer; conflating them produces design confusion. Layers below L2 are platform substrate (memory, ownership, I/O); L2 and above are the protocol proper.
Layer |
Concern |
What it sees |
Doc that specifies it |
|---|---|---|---|
L0 — Memory substrate |
Real buffers, MMIO, queues, pools, peripheral FIFOs; allocation, lifetime, cache, DMA |
platform-specific memory backends |
|
L1 — Views and ownership |
Refcounted memory views, ropes (chains of views), the TLV-as-cast |
|
|
L2 — Frame envelope |
Slice the byte stream into framed units; verify integrity; carry wire-time |
|
|
L3 — TLV semantics |
Interpret the type code; recurse into structured (PL=1) containers |
|
|
L4 — Graph endpoint logic |
Route TLVs to vertices, fan out to subscribers, enforce QoS / ACL, manage liveness, forward |
paths, vertices, edges, schemas, settings |
02-graph-model.md (this doc), 03-addressing.md, 04-communication-flows.md |
L5 — Application semantics |
What the bytes inside a |
application-defined |
application code |
The substrate layers (L0 and L1) are what give libtracer zero-copy reach. A TLV in flight or at rest is a view tree over real memory, not a decoded message struct. The wire bytes IS the in-memory representation IS the graph node — across boundaries, the trailer attaches/strips (01-data-format.md) but the payload bytes are invariant.
flowchart BT
L0["L0 — Memory substrate<br/><i>real bytes: heap, MMIO, DMA, pbuf, pool</i>"]
L1["L1 — Views and ownership<br/><i>refcounted segments, ropes, TLV-as-cast</i>"]
L2["L2 — Frame envelope<br/><i>header + payload + optional trailer</i>"]
L3["L3 — TLV semantics<br/><i>type code, opt.PL recursion</i>"]
L4["L4 — Graph endpoint logic<br/><i>vertices, paths, subscriptions, forwarding</i>"]
L5["L5 — Application semantics<br/><i>what the bytes mean</i>"]
L0 -- "alloc / release / cache hooks" --> L1
L1 -- "view_as_tlv (zero-copy cast)" --> L2
L2 -- "type byte + opt.PL" --> L3
L3 -- "TLV registry: VALUE, PATH, FWD…" --> L4
L4 -- "read / write / await on handle" --> L5
style L0 fill:#fef3c7,stroke:#92400e
style L1 fill:#fef3c7,stroke:#92400e
style L2 fill:#dbeafe,stroke:#1e40af
style L3 fill:#dbeafe,stroke:#1e40af
style L4 fill:#dcfce7,stroke:#166534
style L5 fill:#fce7f3,stroke:#9f1239
The type byte sits at the L2 / L3 boundary. It is carried in the wire header (so a router can decide whether to recurse without parsing payload) but its meaning is L3. A pure-framing parser that just dispatches by length + CRC could ignore type entirely; a TLV-aware router uses type (and opt.PL) to decide whether to walk into nested children.
Priority is NOT an L2 concern. The opt byte carries no priority bits — priority is transport-time and per-link, not coherent across the network. A router that wants priority-aware dispatch reads :settings.priority once per subscription (L4) and caches it. See 01-data-format.md §why no priority bits.
Implementations MAY refactor type out of the wire header (into “first byte of payload”) in a future major version without semantic change; this is a layout question internal to L2/L3, not a protocol-level decision.
The same-substrate insight¶
This is the load-bearing technical claim of the libtracer protocol.
A TLV in memory IS a graph node IS the wire bytes.
In most middleware:
The wire encoding is one representation (CDR, Protobuf, Cap’n Proto, Zenoh’s
z_encoding).The in-memory message struct is another (decoded fields).
The routing-topology graph is a third (separate metadata).
In libtracer, all three collapse into one. The mechanism: buffer chains of views over real memory.
The two compositions: storage and meaning¶
The same-substrate collapse hides one subtlety worth making explicit, because conflating it is the most common design error here: the same bytes participate in two orthogonal composite trees, on different axes.
Memory composition |
TLV composition |
|
|---|---|---|
Composes |
storage — where bytes physically live |
meaning — what bytes are |
Leaf |
a view ( |
an opaque TLV ( |
Composite |
a rope — a chain of views across segments |
a structured TLV ( |
Layer |
||
Grows by |
|
nesting children end-to-end |
Both are the Composite pattern, but they compose different things and are decoupled. That decoupling is precisely what makes zero-copy possible: a node’s meaning (its TLV tree) is independent of how its bytes are physically chunked (its rope). Three consequences fall out, each a load-bearing rule:
A rope is not a TLV list. A “list” (a structured TLV with homogeneous children) is meaning; a rope is storage. A rope may hold the bytes of a TLV list, but it is blind to TLV structure.
A view boundary may fall anywhere — including mid-TLV-header. Because the axes are independent, a frame’s 4-byte header can straddle two segments (an lwIP
pbufchain, a DMA ring wrap). A conforming decoder must therefore link-walk the rope, not assume a contiguous buffer:meaning → [ one TLV: header(4) | payload(6) ] (one logical node) storage → [ segment A: 06 40 12 ] [ segment B: 00 | 02 00 02 00 ] ^ the header splits across the A/B boundaryA
FWDuses a rope but is not one. The remote-operation envelope (07-host-embedding.md) composes meaning (op + routes + the payload TLV); the bytes a hop forwards stay a rope. A forward hop is “adjust the route heads, re-emit the rest via scatter-gather, never copy” — the forwarder never inherits or becomes a rope.
The two sections below are this same point made concrete: How nested TLVs work is the meaning axis; How that structured TLV exists in memory is the storage axis.
How nested TLVs work structurally¶
When the PL (payload-is-structured) bit is set in the header opt byte, the payload is interpreted as a sequence of child TLVs concatenated end-to-end. Each child has its own header (4 or 6 bytes per 01-data-format.md, depending on opt.LL) and optional trailer; any child may itself have PL=1 for further nesting.
Outer structured TLV (PL=1, e.g. PATH, SETTINGS, FWD, or a user-range record):
+-----------+--------+----------+
| type=0xXX | opt=PL | length | header (4 bytes default; 6 if LL=1)
+-----------+--------+----------+
| inner TLV 1: header + payload |
+--------------------------------+
| inner TLV 2: header + payload |
+--------------------------------+
| inner TLV 3: header + payload |
+--------------------------------+
| optional outer trailer | trailer (0 / 4 / 6 / 8 / 10 / 12 / 14 / 16 bytes)
+--------------------------------+
Inner TLVs typically carry no trailer of their own — the outer’s CRC (if present) covers the whole concatenated content. A forwarder that wants to split children out and re-route them independently MAY emit them with their own trailers, paying the per-child cost.
This structured TLV IS the graph node. To walk a vertex’s children: parse the children, iterate them, recurse (iteratively, per 01-data-format.md) into any with PL=1.
How that structured TLV exists in memory¶
Underneath, each inner TLV is represented as a view: a struct holding {owner_segment, offset, length} where owner_segment is a refcounted pointer to the real memory backing the buffer. The graph “contains” inner TLVs by holding views into the parent’s memory.
Real memory (received from socket):
[TCP recv buffer; 4 KiB; refcount=1]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Outer structured TLV view:
{ owner = recv_buffer_segment,
offset = 0,
length = 1024 } refcount on segment += 1
Inner TLV 1 view:
{ owner = recv_buffer_segment, // same backing memory
offset = 8 + len_of_outer_length_field,
length = inner_1_length } refcount on segment += 1
Inner TLV 2 view:
{ owner = recv_buffer_segment, // same backing memory
offset = 8 + len_of_outer_length_field + 8 + ... ,
length = inner_2_length } refcount on segment += 1
Operations that look like data manipulation — split a list into two, concatenate two lists, insert a new child, slice off the trailing N children — do not move bytes. They construct new view structs whose owner_segment field bumps the refcount of the underlying buffer.
When all views into a segment are released, the segment’s refcount drops to zero, the segment’s destroy callback fires, and the underlying memory is returned to whatever pool / allocator owns it (heap free, recv-pool return, mmap unmap, etc.).
Structured TLV as abstraction, memory as rope¶
There is a careful distinction between the structured TLV as a logical container and the memory backing it.
A structured TLV’s logical content is “an ordered sequence of children.” That definition says nothing about how the children’s bytes are laid out in memory. In practice:
Wire-receive case: the structured TLV has just been reconstituted from a transport buffer. All children’s bytes live contiguously in one segment. The “rope” has one link — a flat buffer.
In-memory assembled case: the structured TLV was built up via append / concat / split operations. Different children may live in different segments — possibly received from different transports, possibly carved out of a memory-mapped region, possibly synthesized from static data. The rope has many links.
Logical structured-TLV view:
┌──────────────────────────────────────────┐
│ child_0 child_1 child_2 child_3 ... │
└──────────────────────────────────────────┘
Underlying rope (in-memory case):
┌─ view ─┐ ┌─ view ─┐ ┌─ view ──────────┐
│ child_0│ │ child_1│ │ child_2 child_3 │
│ in seg │ │ in seg │ │ in segment C │
│ A │ │ B │ │ │
└────────┘ └────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
segment A segment B segment C
(refcounted) (refcounted) (refcounted)
Operations on a structured TLV do not move bytes:
concat(C1, C2)produces a new container whose rope is the concatenation of C1’s view chain and C2’s view chain. Refcounts on the underlying segments are bumped per child.split(C, K)produces two containers whose ropes share underlying segments with the original. The view chain is partitioned at child K.insert(C, K, child)produces a container whose rope isC[0..K-1] + child + C[K..]. The new child becomes another link in the chain.
Serialization is the rope-to-flat-buffer walk: when a structured TLV is sent on the wire, the serializer iterates the rope in order and emits bytes contiguously. The wire form IS contiguous; the in-memory form is NOT required to be. The proof obligation below guarantees that this walk produces the same bytes regardless of how the rope was assembled.
The parser must handle both substrates (01-data-format.md §two parser contexts):
A wire-receive parser walks byte offsets within one buffer.
An in-memory walker steps across view boundaries when crossing from one rope link to the next.
The same iterative pattern (recurse on PL=1, bound depth at 32) applies to both. Implementations typically share the parsing logic with two different cursor advance functions.
Spec-level proof obligation¶
Any sequence of mix / split / concat operations on a view tree, followed by a
serialize_to_wire()walk, MUST produce the same bytes as if the corresponding mutations had been applied to a fresh buffer.
This invariant is testable: construct a view tree by parsing wire bytes, mutate it, serialize, and compare to the wire-bytes equivalent constructed from scratch. The reference implementation has core/tests/substrate_test.cpp exercising this.
A second-language implementation that fails this invariant is not conforming, regardless of whether its wire output is otherwise valid.
Dispatch keyed on canonical PATH TLV bytes¶
This is the structural rule that lets 03-addressing.md §static path handles work. The graph runtime’s vertex map is keyed on the bytes of the PATH TLV’s payload, not on the string form of the path:
A vertex registered as
/sensor/templives in the map at the key whose bytes are the canonical PATH TLV payloadNAME("sensor") + NAME("temp").A write whose path argument is a build-time
.rodataPATH TLV byte literal hashes / compares against that same key — no string is involved at any point.A write whose path argument is the equivalent string
"sensor/temp"is canonicalized into the same PATH TLV bytes once (by the slow-path string entry, if the implementation provides one) and then dispatched against the same key.
Implication. Two paths that name the same vertex MUST canonicalize to byte-identical PATH TLV payload bytes. The canonicalization rules in 03-addressing.md §path canonicalization are the spec for this; conformance test vectors at tests/conformance/vectors/v1/path_canonical/ check byte equality.
flowchart LR
BL["build-time literal<br/>.rodata PATH TLV<br/>(handle = &bytes, len)"]
IR["init-time registered<br/>heap PATH TLV<br/>(handle = pointer)"]
SR["string-form path<br/>(slow path only)"]
CB["canonical PATH TLV bytes"]
DT["dispatch table<br/>(byte-equality key)"]
V[/"vertex at /sensor/temp"/]
BL -- "no parse, no alloc" --> CB
IR -- "validated once at init" --> CB
SR -- "parse + canonicalize<br/>(P0 may omit)" --> CB
CB --> DT
DT --> V
style BL fill:#dcfce7,stroke:#166534
style IR fill:#dcfce7,stroke:#166534
style SR fill:#fef3c7,stroke:#92400e
style DT fill:#dbeafe,stroke:#1e40af
Why this matters at L4. The dispatch table is a hashmap (or radix tree, or whatever the implementation uses) whose key is the PATH TLV’s payload bytes. Insertion, lookup, and removal all see those bytes — never a parsed string. This is what makes path-handle dispatch O(1) on the hot path: the handle already holds the bytes that the dispatch table is indexed by. There is no resolution step at all, just a hash + memcmp.
The string-form entry point is a courtesy for hosts that don’t care about µs-class hot paths. On the wire, in storage, and through the dispatcher, paths are PATH TLV bytes.
Buffer ownership and refcounts¶
Segment lifecycle¶
created (refcount = 1) ← initial owner (e.g. transport rx)
|
+─→ view created (refcount += 1)
| view created (refcount += 1)
| view destroyed (refcount −= 1)
| view destroyed (refcount −= 1)
| ...
|
↓
initial owner releases (refcount −= 1)
|
↓
last view released (refcount drops to 0)
|
↓
destroy callback fires; memory returned to pool/allocator
Required atomic operations¶
Implementations on multi-threaded hosts MUST use atomic refcounts with these memory orderings (canonical Boost intrusive_ptr pattern):
Operation |
Order |
Why |
|---|---|---|
Increment (clone view) |
|
Caller already holds a reference; data dependency travels via that existing reference |
Decrement (release view) |
|
release: flush all writes before someone else observes count drop; acquire: if we observe drop to zero, sync with all prior releases |
Read for inspection (debug, metrics) |
|
Pairs with each decrement; gives consistent snapshot |
Weak-to-strong upgrade (CAS loop) |
|
Same logic as inc + sync with last decrementer |
Rationale is expanded in 01-data-format.md §refcount memory ordering. The reference implementation uses C++ std::atomic; implementations in C (<stdatomic.h>), Rust (Arc<T> / AtomicUsize), or any other language MUST implement equivalent semantics.
Single-threaded mode¶
For Cortex-M0/M0+ (no LDREX/STREX) and bare-metal single-threaded contexts, an implementation MAY substitute plain (non-atomic) integer refcount, provided the application guarantees no cross-thread sharing of segments. The reference implementation exposes this as LIBTRACER_NO_ATOMIC=ON.
Ownership transfer at endpoint delivery¶
When a transport module receives bytes from the wire, it constructs a top-level view over the received memory and hands it to the router via the recv callback. The router walks the view tree, finds the destination endpoint, and delivers the view to the endpoint’s queue.
At delivery, the view’s ownership is transferred to the endpoint — meaning the endpoint takes the existing refcount, no new copy is made. The transport module relinquishes its reference (refcount decrement; the segment survives because the endpoint now holds the count).
If multiple subscribers are attached, the view is cloned (refcount bumped per subscriber, no byte-level copy). Each subscriber sees the same backing memory through its own view struct.
This is the mechanism that makes “as fast as Cap’n Proto with pub/sub semantics” credible.
Read is zero-copy; write is single-copy where the medium demands¶
The asymmetry is real and worth naming explicitly.
Read paths¶
A reader that walks the graph or consumes a delivered TLV does so through views. No bytes are copied. The reader gets a (pointer, length) pair; it can memcpy into its own buffer if it wants a private copy, but the protocol does not impose this.
This is true regardless of where the bytes came from: a TLV constructed in-process from a static buffer, a TLV received over TCP and held in the recv-buffer segment, a TLV materialized from a memory-mapped GPIO register — all are reads through views.
Write paths¶
A write of a TLV constructed in-process is similarly zero-copy at the API boundary: the caller hands over a TLV (which is a view tree); ownership transfers to the router. No copy.
The medium under a transport module determines whether a copy happens at the wire boundary:
Transport |
Send-side copy? |
Receive-side copy? |
|---|---|---|
In-process / in-thread |
none |
none |
|
one (kernel |
one (kernel |
|
one (kernel |
one (kernel |
|
none (the segment IS shared mem) |
none |
|
none (loan-publish-borrow) |
none |
|
one + per-CAN-frame fragmentation |
one (HAL ISR copies each frame to RX buffer) |
|
one (DMA or per-byte) |
one (RX buffer, then framed-TLV view over it) |
|
none on the data plane |
none |
The receive side on a stream/byte transport (UART, CAN, I²C, TCP) intrinsically needs an RX buffer because the bytes arrive incrementally and the framer needs to reconstitute a complete TLV. Once reconstituted, the framed TLV is a view over the RX buffer, and from that point on no further copies happen — the same view propagates through the router, fans out to subscribers, and is read by application code.
A subscriber that processes the TLV synchronously and releases its view immediately allows the RX buffer segment to be returned to the transport’s free pool quickly. A subscriber that holds the view (e.g. enqueues for later processing) keeps the segment alive — backpressure on the segment pool is one of the QoS knobs (queue_max_bytes).
The trailer enables payload-bytes invariance across boundaries¶
The wire-format trailer (trailer_ts, trailer_crc; see 01-data-format.md) is what makes the read/write asymmetry above clean. The trailer is append-only at egress, strip-only at ingress — the payload region is never touched.
This means the same payload bytes flow through every state of the TLV’s life:
State |
Form |
|---|---|
Stored at a vertex (graph data) |
|
Recorded to disk by a recorder module |
|
Sent on a transport |
|
Received over a transport |
|
Forwarded to another transport |
|
Replayed by a recorder module |
|
In every state the payload bytes are byte-identical to every other state. A view that names those payload bytes survives unchanged through forward hops, recorder round-trip, and subscriber fan-out. Subscribers can compute hashes / equality / signatures over the payload region without worrying about whether the TLV is currently in flight or at rest.
A subscriber that wants the application-domain timestamp reads it from a sibling TIME TLV inside the payload (if present) — that lives inside the payload bytes and survives every transition. The wire-trailer TS is for transport diagnostics only; it does not survive at-rest storage.
Subtree subscriptions, branch writes, write-creates (RFC-0005)¶
Three ratified write/delivery semantics that make the vertex tree observable and writable at any granularity with the same three-call API:
Every subscription is a subtree subscription (vertical bubbling). A
SUBSCRIBERedge on vertex V observes writes to V and to every descendant (a leaf subscription is the trivial case). A write at W delivers — once per subscriber — to the subscribers of W and of each ancestor of W, carrying the written TLV as-is (the frame at the producer’s granularity; no re-encoding, no tagging envelope; provenance beyond that travels in the data). Local delivery is the usual view clone; remote delivery rides the existing return-routeFWD{WRITE}path unchanged. The write path stays near-free when nobody listens: per-vertex listener counters (maintained at subscribe/unsubscribe and summed from ancestors at vertex creation) mean an idle write pays one relaxed atomic load and never walks ancestors.A branch write decomposes. A write whose payload is a
POINTtree (05 §0x07) rooted at the target vertex lands each value-carrying node at the corresponding descendant vertex as a refcount subview of the written frame (zero copy), creating missing vertices on the way; each covered subscription point is notified once with its slice. Values are the truth at the vertices where they land; a branch is a view. The branch is not a transaction — admission (shape + ACL) is all-or-nothing, application is per-leaf; cross-leaf snapshot coherence is the coherent-sampling(origin, ts)group (ADR-0019), never a write-side lock.A data write creates its target (
mkdir -p) when the vertex does not exist, gated by the existingCREATEaccess bit on the nearest existing ancestor’s effective ACL.:fieldwrites,read, andawaitkeeptr::path::not_found.
Reads keep the one-store-per-vertex invariant: a write at any granularity lands in the same canonical last-known-value a read serves, and a read returns the latest stored value — ≥ what any subscriber of that path last saw, never behind a notification, legitimately newer. The producer owns cadence: rate caps, flush intervals, dirty tracking and timers are application concerns; batching several subtrees is N self-contained frames in one send(iov), never a wire batch container.
Schema and field discipline¶
A vertex exposes a schema describing every writable field. The schema lives at <vertex>:schema as a read-only structured TLV (typically a POINT whose children describe each field, or a SETTINGS-shaped record).
Core writable fields (frozen for v1)¶
Field path |
Type |
Writable |
Meaning |
|---|---|---|---|
|
SUBSCRIBER |
yes |
Subscription record N |
|
sequence of SUBSCRIBER |
read-only; write to |
Full list (read) or new slot (write) |
|
u8 enum |
yes |
|
|
u8 enum |
yes |
|
|
u32 |
yes |
N samples retained for late joiners |
|
u64 |
yes |
Max time between writes before liveness fault |
|
u8 |
yes |
|
|
u32 |
yes |
Per-subscriber queue cap (back-pressure threshold) |
|
u8 |
yes |
Subscriber heartbeat rate; 0 = no liveness check |
|
u64 |
read-only |
Wall-clock of last write observed |
|
u32 |
read-only |
Counter |
|
structured TLV |
read-only |
Self-describing schema of fields and types |
|
UTF-8 |
yes (with permission) |
Human-readable description |
|
ACL |
yes (with permission) |
Access control list |
Stale and invalid values¶
A producer that has no good value to publish — a sensor that faulted, a reading not yet taken — does not need a dedicated “invalid” wire field. The two distinct concerns map onto mechanisms that already exist:
Stale (the value is old, or the producer went quiet) is consumer-derived, never a flag the producer sets:
Sample age: the optional wire timestamp (
opt.TS, 01-data-format.md) carries when the sample was taken; a consumer treatsnow − ts > toleranceas stale.Producer liveness:
:settings.deadline_nsplus the read-only:liveness.last_seen_ns/:liveness.missed_deadlinesfields (above) say whether the producer is still writing within its contract. A missed deadline is the canonical “this vertex went stale” signal, and it is observable without the producer doing anything.
Invalid / fault (the producer is alive but its value is meaningless right now) is a
STATUS=ERROR(<reason>)delivered in place of a VALUE — the same pattern the transport plane uses to surfaceSTATUS=ERROR(TRANSPORT_DOWN)(07-host-embedding.md). Delivery is an ordinary write (CONTEXT.md §delivery is a write), so a fault reaches subscribers through the same edge as a value; a type-aware consumer distinguishes aSTATUS(type0x09) from aVALUE(type0x01) by its type code and reacts (hold last-good, alarm, fail over) exactly as it would for a liveness fault.
This keeps the data plane byte-agnostic: L4 never interprets a payload to decide “is this valid.” Validity is either a property of time (stale, derived from ts/liveness) or an explicit typed record (STATUS/ERROR), never a magic value or a per-VALUE flag bit.
Observing structural change¶
Watching a parent’s child set change (devices appearing, a scanner discovering a 1-Wire/Modbus node) needs no dedicated event type — it falls out of composite subscription:
Subscribe to the composite parent. Subscribing to a composite vertex is the subtree subscription — every subscription observes its vertex and all descendants (RFC-0005 vertical bubbling). The subscriber receives each descendant write as the written TLV as-is (the producer’s own frame — a leaf
VALUE, or a whole branchPOINT); the aggregate snapshot remains available as a read. A newly appeared child surfaces as its first write bubbling up — and since a data write creates its target (write-creates), appearance is the first write; a child’s value change surfaces the same way. There is no separate:children_changedfacet — that would duplicate what subtree delivery already does. (Wire-level concrete-path tagging of remote deliveries remains the draft RFC-0003 proposal.)Enumerate the current members with
read(<parent>:children[]), which returns the subtree members (not SPECs — the write-spec / read-members asymmetry, 05 §SPEC). The common pattern is read-snapshot-once on join, then subscribe (to the parent) for the tail.
Open — child removal. Appearance and value-change are covered above, but how a child removal is delivered to a composite subscriber (a tombstone delta? a STATUS=ERROR(NOT_FOUND) at the child’s path? snapshot-diff only?) is not yet specified. Because it is wire-observable behavior it is an RFC/ADR-level decision, tracked in #66; the DELETE access-mask bit (05 §ACL) gates the right to remove but does not define the notification.
Module-namespaced extension fields¶
A transport module like transport_tcp MAY add per-subscriber settings such as :subscribers[N].settings.transport_tcp.send_buf_kb. Rules:
Module fields MUST live under their own module name (here,
transport_tcp).Module names MUST match the module’s directory in
libtracer/modules/for the reference implementation; cross-implementation module-name uniqueness is a registry concern.Module fields MUST appear in the vertex’s
:schemaoutput if they apply to that vertex.Reading a module field on a vertex where that module is not active returns
ERROR{tr::schema::not_found}.
The graph imposes no shape¶
A vertex’s writable fields are whatever the schema says. The protocol specifies a small set of mandatory core fields and a namespacing rule for extensions; it does NOT specify:
How many subscribers a vertex may have.
How many child vertices a parent vertex may have.
The shape of an endpoint’s data payload (a
VALUETLV’s bytes are opaque to the protocol).The relationship between sibling vertices (e.g.,
/camera/frame[0]and/camera/frame[1]share a timestamp domain only if the application chooses).Whether a vertex is backed by RAM, MMIO, file, or function-on-read.
This is by design. 06-user-data-packing.md shows the full dynamic range — from a single boolean to a streaming 1 GB/s ADC — using the same vertex/edge primitives.
Cross-walk to other middleware¶
For readers familiar with existing systems:
Concept |
libtracer |
ROS 2 |
DDS |
MQTT |
Zenoh |
|---|---|---|---|---|---|
Named address |
path ( |
topic ( |
topic |
topic |
keyexpr |
Producer |
writes to path |
publishes to topic |
DataWriter on topic |
publishes to topic |
put/publication |
Consumer |
subscribes (writes SUBSCRIBER) |
DataReader subscribes |
DataReader on topic |
subscribes |
get/subscription |
Wildcards |
|
not in topic, only in QoS partition |
partition wildcard |
|
|
Single API for ctrl + data |
yes (field-write) |
no (separate services) |
no (DCPS + RPC) |
no (extra packets) |
no (separate |
Wire format |
TLV (this doc) |
DDS-CDR |
CDR |
proprietary |
Zenoh proto |
Discovery |
module (mDNS/static/gossip) |
DDS Simple Discovery |
RTPS |
broker |
Zenoh scouting |
Cross-node forwarding |
core (stateless |
rmw bridges (rmw_zenoh) |
DDS routing service |
bridges |
Zenoh routers |
The unifying feature: in libtracer, every cross-walk row is the same primitive (a TLV at a path), not a separate API.
Graph data vs in-flight messages: the FWD envelope¶
The TLV substrate plays two distinct roles:
Graph data — what’s stored at a vertex. Identity = vertex path. Content = the user’s payload, possibly a structured TLV with sibling metadata (e.g.,
TIME). No routing metadata, no trailer (trailer-less at rest).In-flight remote operation — what crosses a transport between nodes. Content = a
FWDTLV (type0x0F, 05-protocol-tlvs.md) wrapping the payload: FWD is structured (PL=1) carrying the op code, thedstroute (the explicit source route to the target), thesrcroute (the accumulated way back), and the payload TLV last.
Both roles use the same TLV substrate — same wire format, same in-memory view tree. The difference is structural and lives in the FWD envelope’s presence.
The shedding rule (mandatory)¶
At each forward hop (the first dst segment names a transport link):
Strip the leading
dstsegment — the route shrinks toward the target.Prepend the node’s NAME for the inbound link to
src— the return route grows.Re-emit the rest of the frame untouched over the named link (fresh trailer per the egress transport). The hop keeps no per-request state.
At the terminus (the first dst segment names a local vertex):
Decode the frame and apply the op to the local vertex.
Shed the
FWDenvelope — the graph stores only the bare payload TLV, trailer-less at rest. No routing metadata lands in graph data.Reply with a fresh
FWD{REPLY}whosedstis the accumulatedsrc— the reply retraces the request’s route hop-by-hop.
Forwarding is loop-free by construction: dst is consumed monotonically per hop, and a dst that revisits a node is malformed (ERROR{tr::path::invalid}). There is no duplicate detection and no hop counter — none is needed, because every remote endpoint is addressed by an explicit source route. (0x0D ROUTER is a reserved, decodable wire code with no implemented mechanism.)
Why this matters¶
Graph reads are clean. A subscriber reading
/net/can0/wheel/leftgets the bare data TLV — same shape regardless of whether the value originated locally or arrived over CAN. No envelope pollution; no need for application code to skip routing metadata.Recorder is simple. Recording a vertex’s value writes the bare data TLV. Replay does NOT replay the original envelope (which would alias the original sender’s route); replay is a fresh write from the recorder’s identity.
Same substrate, two clean roles. The protocol does not have separate “wire format” and “graph format” — it has one format with one optional wrapping that distinguishes the two roles.
A worked sequence¶
sequenceDiagram
autonumber
participant STM as STM32 client
participant LB as Linux forwarder
participant ESP as ESP32 terminus
participant V as /wheel/left
STM->>LB: FWD{ WRITE, dst=/esp/wheel/left, src=/stm, VALUE }
Note over LB: strip "esp" from dst<br/>prepend inbound-link NAME to src
LB->>ESP: FWD{ WRITE, dst=/wheel/left, src=/can0/stm, VALUE }
Note over ESP: first dst segment is local ⇒ terminus<br/>shed the envelope, store bare VALUE
ESP->>V: write (trailer-less at rest)
ESP-->>LB: FWD{ REPLY, dst=/can0/stm }
LB-->>STM: FWD{ REPLY, dst=/stm }
A dst that would route the frame back through a node it already crossed cannot be expressed by a well-formed shrinking route — a revisit is rejected with ERROR{tr::path::invalid}. No cycle can persist.
This shedding rule is what keeps the global topology safe for any shape — see 07-host-embedding.md.