Reference 02 — Graph model

Audience: implementers writing the router/dispatcher; anyone reasoning about zero-copy semantics. See also: 04-communication-flows.md for the operation surface; 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 POINT or a SETTINGS-shaped record) returned at <vertex>:schema, enumerating the writable fields the vertex exposes. Two parts with defined precedence (RFC-0010 §B.2): a synthesized protocol part, authoritative for protocol machinery — and, when the owner installed a field descriptor table, an owner part (NAME "app" SETTINGS{…}) served verbatim, authoritative for settings.app.*. Read-only.

What the synthesized part emits (RFC-0022). The synthesized part enumerates neither :subscribers[] nor :settings.*, :liveness.* or :acl. graph_t::read_schema emits exactly:

POINT{ NAME <vertex name>
       SETTINGS{ }                  // the implemented protocol knobs: NONE
       [ NAME "app" SETTINGS{…} ]   // only when a descriptor table is installed
}

The core knob enumeration is empty, and therefore complete: RFC-0022 §3.B deletes settings_t outright, so the vertex’s :settings core namespace holds nothing to enumerate and the synthesized part can neither over- nor under-report it. The empty SETTINGS is emitted, not omitted, so the record keeps its shape whatever the vertex declares. The :liveness.* and :description rows below are fictional and are marked as such.

  • Forwarder (router): the stateless component that routes FWD frames between a node’s local graph and its named transport links — each hop strips the whole leading dst mount run and grows src with the way back. To a downstream subscriber a forwarded delivery is indistinguishable from a local write. “Stateless” is the bare hop, and is already qualified for compact delivery flows, which do leave per-link label state on the hops they cross (05-protocol-tlvs.md §Route-handle frames); RFC-0027 (accepted 2026-08-15, implemented) adds a second such qualification for hops that mint a path label — one that is opt-in and off by default, so a hop with no injected label table is the bare stateless hop still (05-protocol-tlvs.md §0x06 §path label element).

  • 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

09-memory-substrate.md

L1 — Views and ownership

Refcounted memory views, ropes (chains of views), the TLV-as-cast

segment, view, refcount, rope chain

08-views-and-ownership.md

L2 — Frame envelope

Slice the byte stream into framed units; verify integrity; carry wire-time

length, payload, optional trailer_ts and trailer_crc

01-data-format.md

L3 — TLV semantics

Interpret the type code; recurse into structured (PL=1) containers

type, opt.PL, payload-as-bytes-or-children

05-protocol-tlvs.md

L4 — Graph endpoint logic

Route TLVs to vertices, fan out to subscribers, enforce QoS / ACL, manage liveness, forward FWD frames

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 VALUE mean; what an endpoint’s value represents; control logic over the data

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 -- "decode(view_t) (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 the subscription’s priority (bits 2–4 of its delivery policy, RFC-0022 §3.A) once at admission (L4) and caches it. It was a per-vertex :settings.priority until RFC-0022 observed that one vertex fanning out to a CAN peer and a WebSocket peer has no single priority to hold — which is why nothing ever consumed the knob. 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

storagewhere bytes physically live

meaningwhat bytes are

Leaf

a view ({owner, offset, length} over one segment)

an opaque TLV (opt.PL=0)

Composite

a rope — a chain of views across segments

a structured TLV (opt.PL=1); its type code says what the children mean

Layer

L1 (08-views-and-ownership.md)

L3 (01-data-format.md, 05-protocol-tlvs.md)

Grows by

append / concat — pointer-linking, zero-copy

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 pbuf chain, 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 00 06 ] [ segment B: 00 | 05 61 6C 70 68 61 ]
                                      ^ the header splits across the A/B boundary
    

    (A PATH for /alpha: header 06 00 06 00, body one packed [u8 len][utf8] segment record 05 "alpha". The same applies inside the body — a record’s length byte can fall in one segment and its text in the next, which is why the packed walk reads single bytes through the cursor rather than off a pointer, RFC-0018 §5.1.)

  • A FWD uses 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: Nested TLV structure is the meaning axis; The structured TLV in memory is the storage axis.

Nested TLV structure

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. SETTINGS, FWD, POINT, 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.

The structured TLV 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 is C[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 applies to both: recurse on PL=1, with nesting depth bounded by the receiver’s decode resources rather than by a protocol constant — exhaustion rejects the frame with TLV_NESTING_TOO_DEEP (RFC-0006). 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 exercises it in core/tests/substrate_test.cpp.

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/temp lives in the map at the key whose bytes are the canonical PATH TLV payload — the packed segment records 06 "sensor" 04 "temp" (RFC-0018).

  • A write whose path argument is a build-time .rodata PATH 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 vectors under tests/conformance/vectors/v1/path/ pin the encoded bytes.

        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)

relaxed

Caller already holds a reference; data dependency travels via that existing reference

Decrement (release view)

acq_rel

release: flush all writes before someone else observes count drop; acquire: if the drop to zero is observed, synchronize with all prior releases

Read for inspection (debug, metrics)

acquire

Pairs with each decrement; gives consistent snapshot

Weak-to-strong upgrade (CAS loop)

acq_rel on success, acquire on failure

Same logic as inc + sync with last decrementer

Rationale is expanded in 08-views-and-ownership.md §refcount semantics. 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 a plain (non-atomic) integer refcount, provided the application guarantees no cross-thread sharing of segments. This is a build-time substitution, not a runtime mode: the two refcount flavours are never mixed within one image.

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 — 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.

Fan-out therefore costs one refcount increment per subscriber and no payload traffic, which is what puts pub/sub semantics on the same cost footing as a zero-copy serialization format.


Copy asymmetry between read and write

Reads are zero-copy; writes are single-copy where the medium demands it. 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

transport_unix (Unix domain socket, future)

one (kernel write)

one (kernel read into recv segment)

transport_tcp

one (kernel send)

one (kernel recv into recv segment)

transport_shm (future)

none (the segment IS shared mem)

none

transport_iceoryx2 (future)

none (loan-publish-borrow)

none

transport_can

one + per-CAN-frame fragmentation

one (HAL ISR copies each frame to RX buffer)

transport_uart / transport_i2c (future)

one (DMA or per-byte)

one (RX buffer, then framed-TLV view over it)

transport_rdma (future)

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 is therefore a property of the injected pool — exhaustion surfaces as tr::flow::backpressure (CONTEXT.md §No synthetic limits) — not of a per-vertex knob: the queue_max_bytes knob this paragraph used to name was inert and was removed by RFC-0022 §3.E.

Payload 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)

header + payload

Recorded to disk by a recorder module

header + payload (trailer dropped at ingress to recorder)

Sent on a transport

header + payload + trailer (trailer attached at egress)

Received over a transport

header + payload (trailer validated and dropped)

Forwarded to another transport

header + payload + new_trailer (fresh wire-time, fresh CRC)

Replayed by a recorder module

header + payload + new_trailer

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, and write-creates

Three ratified write/delivery semantics (RFC-0005) 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 SUBSCRIBER edge 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-route FWD{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 takes no vertex lock at all and never walks ancestors — the whole delivery decision is two atomic loads.

  • A branch write decomposes. A write whose payload is a POINT tree (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 LOCAL data write creates its target (mkdir -p) when the vertex does not exist, gated by the existing CREATE access bit on the nearest existing ancestor’s effective ACL. A remote fieldless FWD{WRITE} whose dst resolves to nothing does not create: it answers tr::path::not_found, the same as :field writes, read and await (RFC-0005 §D amendment 1). A peer creates through the creator endpoint (ADR-0059), where creation is typed, catalogued and ACL-gated; the in-process owner of a graph keeps building its own structure by writing to it.

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. A plain read of a branch (a vertex with ≥ 1 registered child) serves the composed branch read (RFC-0016) — the folded POINT tree of its registered subtree, each node’s stored TLV verbatim, the read-side dual of the branch write (READ-denied subtrees pruned; a names-only topology tree when the branch is value-free); leaf reads are unchanged, and cross-leaf tearing remains allowed — the composed reply is a view over the live stores, not a transaction. 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 scatter-gather send, never a wire batch container.


Assign, propagate, and the coalescing sweep

A write is not a primitive. It is the composition of the two irreducible operations it hides (RFC-0008), which act on disjoint planes of a vertex:

Operation

Plane

Effect

assign

state

Replace the vertex’s value (last-writer-wins), advance its monotonic write sequence, append to the history ring if the vertex is a stream, wake any await waiter, and mark the vertex for the next covering sweep. Reads no edge; sends nothing.

propagate

edges

Deliver, along subscription edges, the vertex’s current stored value and the qualifying descendants of its subtree. Takes no value argument — the last-known-value is the single source of truth. Mutates nothing.

write(v, value) is exactly assign(v, value) followed by propagate(v). read and await live wholly in the state plane; await observes assigns at its own vertex and is independent of propagation.

The pair requires retention, and await serves the read contract (RFC-0008 Amendment 2). Both statements follow from one fact: a handler vertex retains nothing — it hands a write to its on_write seam and composes a read from on_read.

  • await is the readiness form of a data read, so after a wake it serves its value through the same role dispatch a read of that vertex serves at that instant. A handler vertex therefore answers an await with its on_read-composed value; it used to answer NOT_FOUND after the awaited write had already reached on_write. A handler with no on_read still answers NOT_FOUND — that degradation belongs to the read contract, not to await — and a branch vertex’s await still hands back its own last-known-value, never the composed subtree fold, because await watches its own vertex’s write sequence. This is wire-visible: a FWD{AWAIT} at a handler terminus went from ERROR to a RESULT carrying the value.

  • assign and propagate refuse by value at a vertex that retains nothing, answering SCHEMA_NOT_FOUND — the contract-mismatch status, deliberately not BACKPRESSURE. propagate takes no value argument, so the accumulate-then-flush pair needs the state plane to hold something between the two calls; at a handler it held nothing and the sweep delivered silence. Use write, which dispatches the seam and delivers eagerly. Only the sweep root is judged: a sweep rooted at a retaining ancestor still walks past non-retaining descendants exactly as before.

The write sequence and pending vertices

Every vertex carries a monotonic write sequence, incremented by every assign — never a hash or a comparison of the value’s bytes. A sweep records, per vertex it includes, the sequence value at that inclusion. A vertex is pending exactly when its write sequence has advanced past the value the last covering sweep recorded.

propagate(root) sweeps the subtree rooted at root, delivers each vertex it selects, and then advances that vertex’s recorded sweep sequence. Two rules keep this consistent:

  1. The root argument selects which vertices flush, not who receives them. When a sweep selects a descendant u, u is delivered to every subscriber that observes it — u’s own edges and every ancestor carrying a subtree subscription, including ancestors above root. One sweep that covers u therefore satisfies both observers at once and clears u’s pending state, so an overlapping second sweep neither double-sends nor misses an observer. Capping delivery at root was rejected: it would force per-(vertex, root) bookkeeping instead of one counter per vertex.

  2. Coalescing is free. Assign overwrites the value and advances the sequence; it does not enqueue. k assigns to the same vertex between two sweeps flush once, with the latest value. A producer may assign at any rate and propagate on a timer at a lower rate; the timer rate is the delivery rate, and only touched vertices ride it.

A default sweep costs O(pending-in-subtree), not O(subtree-size): pending vertices are held in an ordered set of canonical PATH keys, and a subtree is a contiguous prefix range of that order (a parent’s key is a byte-prefix of every descendant’s). A large, mostly-quiet subtree with three pending leaves flushes those three and touches nothing else.

A branch write composes with this: the POINT tree decomposes as usual, the store half assigns at each value-carrying descendant, and one propagate at the branch root then flushes exactly the touched set.

Delivery mode

Delivery mode is a per-vertex, value-agnostic attribute governing whether an ancestor’s sweep includes that vertex. It is not a per-subscriber filter and it never reads a byte.

Mode

An ancestor sweep includes this vertex…

IF_NEWER (default)

only if pending — the structural coalescing flush

UNCONDITIONAL

always — the current value on every covering sweep, a sweep-driven keepalive whose rate is the producer’s timer

EXPLICIT

never — deliverable only by a direct propagate on the vertex itself

Two invariants make the modes coherent:

  • Assign is never gated. Whatever the mode, assign swaps the value and advances the sequence. The mode governs propagation, not storage.

  • A direct propagate always delivers its argument. The mode governs only the descendants a sweep pulls in, which is what makes EXPLICIT reachable at all.

Delivery mode is host state on the vertex, defaulting to IF_NEWER. It is not carried in SUBSCRIBER.qos_settings — the source vertex owns the policy, not the observer. Configuring it from a remote peer over the vertex :settings path is specified but deferred; the core semantics need only the attribute and its default. The value-based ON_CHANGE filter and the min_interval_ns / keepalive_ns throttles that once lived in qos_settings are removed for good: the runtime never compares stored bytes to decide delivery, because a vertex never parses its bytes and comparing an N-byte value costs the same memory traffic the delivery would. delivery_compact (label compaction) is orthogonal — it concerns how a route is encoded, not whether a value is delivered — and is retained.

Wire mapping

The wire is unchanged by the split. A FWD{WRITE} carrying a VALUE and arriving at its terminus means assign the addressed vertex, then propagate it — delivering to that vertex’s own subscribers, local and remote, and bubbling to ancestor subtree subscriptions. Because the terminus vertex is the argument of that propagate, it is delivered regardless of its own delivery mode: a directed write always lands.

A producer’s selective subtree flush reaches a remote subtree subscriber as one FWD{WRITE} per selected vertex, driven by the single host propagate, exactly as a local subscriber receives one delivery per selected vertex. No wire batch primitive exists; propagate simply drives the sends it selects.

The FOLD emission mode (RFC-0025 §4.1.2, Amendment 3 clause 5) is the producer’s alternative to that one-per-vertex fan-out, and it is opt-in per call: propagate(v, emission_mode_t::FOLD) emits one branch-write frame for the swept subtree — the folded POINT tree of the selection, node shape byte-for-byte RFC-0005 §B’s, the root carrying its own leading NAME — where the default emits N. Selection is identical in both modes; only the framing differs, and propagate(v) is unchanged. The terminus is untouched: §B’s decomposition already hands each covered subscription point the smallest subview covering every value at-or-below it, so a covered leaf sees byte-identical bytes either way. It is one frame per subtree, never a container spanning several — two disjoint subtrees are two calls and two frames.

The fold refuses, before delivering anything and before draining a single sweep mark, when a selected vertex cannot contribute a §B-legal node: a stored value that is not a single trailer-less VALUE TLV answers tr::schema::type_mismatch. Trailer-carrying nodes are therefore rejected, not silently stripped — admissible only because Amendment 1 moved sample time out of the trailer into payload TIME children. A selected STREAM vertex refuses on the same rule: its since-flush list has no seat on a node that admits at most one VALUE, and the BATCH (0x80) record that would seat it is not a VALUE either, so §B’s strictness rejects it as “any other child type”. A refusal consumes nothing, so the caller falls back to the default emission with no delivery lost.

Stream drain semantics

The write-sequence coalescing above is the stored-value semantic (last-writer-wins: flush the latest once). A stream vertex — one with a bounded history ring — is a queue: its contract is “observe every buffered entry,” not “the latest.” Its propagation is a drain, in order, of the entries appended since the previous flush. Propagate dispatches on the vertex role.

A producer never queues; the queue belongs to whoever consumes it (RFC-0025 §4.6.1, Amendment 2). Writing is always the lock-free path — there is no producer-side ring on any delivery class, at any depth. Depth materializes at the receiving vertex: a party that wants a queue makes its own target vertex a STREAM, and that ring is bounded in bytes by that vertex’s own injected mem::block_source_t — per injection point, never a pool shared across vertices or planes (ADR-0079 Amendment, 2026-08-20: a shared source collapses to 0.01x of its single-thread rate at T = 24, while a per-thread source scales at 0.46x). The pressure contract binds at that receiver ring: best-effort sheds oldest with FLOW_ADDRESS_SHIFT_GAP and loss accounting; reliable answers FLOW_BACKPRESSURE back to the producer, which — knowing the consumer’s sampling rate — slows and shapes its traffic to it. Order across writers is ADR-0019’s per-producer monotonic stamps, never a ring-minted sequence number. The measured basis: the retired producer-side ring cost a fixed +29 ns (+54 %) per write regardless of depth, and four writers on one vertex fell from 4.59 M/s lock-free to 1.73 M/s through it. Batch folding is the preferred stream carrier — 32.0x per-sample amortization (2.54 ns/sample) at ~9.3 B/sample retained against 172.

The host-side spelling of these operations is on ../modules/graph.md; nothing in this section requires a particular signature.


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 — ⚠️ four rows are unimplemented, see below)

Field path

Type

Writable

Meaning

:subscribers[N]

SUBSCRIBER

read; write is payload-discriminating

Subscription record N — see the rule below

:subscribers[]

sequence of SUBSCRIBER

read-only; write to [] appends

Full list (read) or new slot (write)

:settings.app.<name…>

owner-defined TLV

owner-declared (ro/rw/wo)

Application property field (RFC-0010): declared by the vertex owner in its field descriptor table; undeclared names stay SCHEMA_NOT_FOUND

⚠️ :liveness.heartbeat_hz

u8

unimplemented

Subscriber heartbeat rate; 0 = no liveness check

⚠️ :liveness.last_seen_ns

u64

unimplemented

Wall-clock of last write observed

⚠️ :liveness.missed_deadlines

u32

unimplemented

Counter

:schema

structured TLV

read-only

Self-describing schema of fields and types

⚠️ :description

UTF-8

unimplemented

Human-readable description

:acl

ACL

yes (with permission)

Access control list

⚠️ The four marked rows have no implementation, in either direction (#586). There is no liveness surface, no heartbeat engine and no description field anywhere in the reference implementation; a read or a write of any of them answers tr::schema::not_found (0x0031) — the ENOTTY contract CONTEXT.md §Field-write declares (“an unsupported one returns SCHEMA_NOT_FOUND — the ENOTTY of an unsupported ioctl”). The complete implemented field set is {children, acl, identity, schema, settings, subscribers}.

They are marked rather than deleted because whether deadline/liveness enforcement is a v1 commitment is an open design question (RFC-0010 liveness is the pending work). Marking removes the fiction without foreclosing either answer; deleting would foreclose one. If liveness lands, drop the markers — if it is ruled out of v1, drop the rows.

The whole flat :settings.<knob> namespace was REMOVED, not deprecated (RFC-0022 §3.B/§4). All seven historical names — reliability, priority, durability, deadline_ns, queue_max_bytes, history_keep_last, store_ref_min_bytes — answer SCHEMA_NOT_FOUND on read and on write, caller-independently. reliability, priority and durability describe one producer→subscriber relationship, not a vertex, so they moved to the subscription’s packed delivery policy (§Subscriber delivery policy below); deadline_ns and queue_max_bytes were inert and had no coherent per-vertex meaning, so they were deleted outright; the two survivors are construction parameters, not QoS, and became owner-side declarations with no wire surface at all (§Storage is declared owner-side, below). There is no deprecation window: the protocol is DRAFT, and of the seven only three ever drove behaviour.

The bare :settings read keeps its container and loses its knobs. It is now SETTINGS{ [NAME "app" SETTINGS{…}] }; a vertex declaring no app fields reads an empty SETTINGS{}, which is honest rather than absent. :settings.app and :settings.app.<name…> are unchanged.

The payload-discriminating :subscribers[N] write

An indexed write is resolved by what it carries (RFC-0009 §D.1):

Payload written to :subscribers[N]

Effect

empty STATUS (09 00 00 00) — the sentinel (05 §0x09)

clears slot N (unsubscribe)

a SUBSCRIBER

replaces slot N’s edge, admitted through the same door as an append — so it passes the SUBSCRIBE gate, not merely WRITE

anything else

TYPE_MISMATCH; the slot is untouched

an N no slot answers to

INVALID_PATH — the slot vector is never grown to reach a wire-supplied index

A [*] selector is not a write selector: :subscribers[*] on a write answers INVALID_PATH, because the WRITE grammar has no wildcard axis.

:subscribers is also addressed whole: :subscribers[N].<anything> names nothing and answers SCHEMA_NOT_FOUND, matching the read half.

Intent

Spelling

subscribe

write :subscribers[] with a SUBSCRIBER

unsubscribe slot N

write :subscribers[N] with the empty-STATUS sentinel

retarget

write :subscribers[N] with the replacement SUBSCRIBER

list

read :subscribers[]

Unsubscribe-then-subscribe remains available and is the only spelling that does not require knowing N; the new record may land in a different slot.

Subscriber delivery policy

Delivery policy is a property of one producer→subscriber relationship, not of the producer (RFC-0022 §3.A). A SUBSCRIBER MAY carry it in its existing SETTINGS child — the same child delivery_compact uses, so this introduced no new wire structure — as one packed 16-bit value under the key delivery_policy:

bits

field

values

0–1

reliability

0 = best-effort, 1 = reliable; 23 reserved

2–4

priority

07, 0 = default

5

durability_request

1 = deliver the producer’s latched last value on join

6–7

delivery_class

0 = conflate (default), 1 = immediate, 2 = batch, 3 = stream (RFC-0025 §4.1) — assigned, not yet honoured

8–15

reserved

MUST be written 0, MUST be ignored on read — never rejected

Absent ⇒ all-zero ⇒ the default behaviour, byte-identically: a sender that predates the policy is a conforming sender. Only durability_request is honoured today (§the transient-local latch, 05 §0x04); reliability and priority are carried and read back, awaiting the transport work that honours them — the honest shape RFC-0022 chose over moving dead per-vertex fields into a new home. Bits 6–7 are decoded but not yet honoured: their default 0 (conflate) is today’s behaviour byte-identically, every core now reads the field, and the subscriber/policy-reserved-bits vector narrowed its description to “bits 8–15 reserved” in the same commit — same bytes (RFC-0025 §4.1.2 clause 7). What still owes #1204 phase 3 is the honouring: the fan-out-edge counter/window and the receiving vertex’s ring. delivery_class = 2 (batch) is the wire encoding of the RFC-0008 assign/propagate flush: accumulation is the source vertex’s own state — LKV coalesce for a plain value, the bounded since-last-flush list for a STREAM — and a flush emits the snapshot or the full list accordingly, never a per-subscriber buffer at the fan-out edge.

No magnitude is packed here. A deadline or a queue bound added later is a magnitude, and a bit-width on a magnitude is a synthetic limit, which this project forbids (CONTEXT.md §No synthetic limits): it would arrive as a full-width field in the subscription’s cold half, never in these bits.

Why per-subscription at all: a single per-vertex reliability or priority has no coherent meaning when one vertex fans out to a CAN peer and a WebSocket peer at once. That is why the per-vertex knobs were writable for a year and consumed by nothing, and why DDS puts exactly these on the reader/writer pair. durability in particular became strictly more correct by moving: one vertex flag used to replay the last value to every subscriber, including the ones that never asked, and there was no way to say “not for me”.

Storage is declared owner-side, and nothing is inherited

The two survivors of the old knob set are not protocol QoS at all — they are construction parameters (RFC-0022 §3.C, Amendment 1):

what

who supplies it

how

STREAM ring depth

the application — a retention intent no peer and no injected resource can supply

graph_t::set_history_depth(v, keep)

the ring’s capacity

the vertex’s own injected mem::block_source_t — the intent above is bounded in bytes by it, and a shortfall surfaces as a shed-with-gap or as backpressure, never as a silent shrink

the source injected at that vertex, never a shared pool

pin amplification ratio K

the deployment — a copy/pin trade (ADR-0042 §3, RFC-0022 §3.D)

graph_t::set_pin_payload_ratio(v, k)

set_history_depth is a host-only intent and stays one: it is declared on the vertex that holds the ring — which, since RFC-0025 §4.6.1, is the receiving vertex of the party that wants depth, not a producer’s fan-out edge. A subscriber that wants a queue makes its own target vertex a STREAM and sizes it with its own injected source; it does not ask a producer to retain on its behalf, and no stream_depth request travels on the wire.

Both are owner-side wiring calls in the shape of set_delivery_mode and set_app_fields — declarations the owner makes host-side after registration — and neither has any wire surface: no peer can read one and none can write one. What was withdrawn is the remote write surface, not owner-side configuration.

Nothing is inherited (§3.F). A declaration reaches exactly the vertex it names: there is no ancestor walk, no cached ancestor reference, and no propagation question when a parent’s configuration changes after its children exist. Both readers therefore stay a single inline load off the vertex’s own extension block — pin_payload_ratio is read on every view-delivered write and the ring depth on every STREAM store, so a walk on either path would be disqualifying under this project’s latency-first ordering.

The RAM consequence runs the same way. Registration no longer carries a policy parameter, so it can no longer force the cold extension block onto a vertex, and neither can an ancestor: strictly more vertices stay extension-less than before RFC-0022. A vertex allocates the block when it is a STREAM, carries a handler, holds app fields or an :acl — or when its owner declares one of the two magnitudes on it, which is the only case storage itself pays for.

The pin is a BORROW, and the application owns the budget

K is the surface on which an application declares whether borrowing is permitted at all, so what the borrow costs belongs here rather than in a footnote:

A pinned value borrows its inbound RX segment for its whole lifetime. Not for the delivery window — for as long as it remains the vertex’s last-known value. On a pooled RX backend that borrow is a pool slot, i.e. receive capacity, unavailable to the transport until the value is displaced or the vertex dies. The library makes the deferred release safe — segment refcounts are atomic, so a borrow outliving the receive frame is never a use-after-free — but the library does not, and cannot, bound the budget: only the application knows its pool geometry and its retention pattern. This is the same division as the embedder-driven collect() idiom and the same Stage-2 posture as user-pinned memory (09): the library guarantees safety, the embedder decides when and whether.

The quantity to size against is therefore live pinned values × segment_bytes. K bounds the waste per value; it never bounds the number of values, so no value of K is a remedy for a retain-heavy workload — a workload that retains twenty pinned values holds twenty segments at every K that pins it at all. That is measured, not argued: at the ESP32-C6 RX geometry (1 KiB slots, 24 slots) every pinning arm — K ∈ {2, 4, 8, 16, ∞} — drove a 29-slot pool to a free-slot floor of 0 with ~10⁶ dropped datagrams the moment the live vertex count crossed the slot count, while the sentinel arm held a floor of 28 and zero drops across the same sweep (bench/README.md §”RFC-0022 §6 — receive-pool occupancy”).

Class guidance, which is also why the shipped default is the sentinel on both targets:

class

posture

why

NARROW

set the sentinel — never pin

a fixed, small RX pool cannot fund an indefinite borrow; same off-by-default-on-NARROW posture as the RFC-0027 label table

MID / WIDE

may borrow freely

the pool is large relative to the retained set, and the borrow is the zero-copy latency win

Note the asymmetry this exposes: latency is bought per write, but the RAM is paid per retained value. A vertex that is written constantly and read constantly borrows one slot; a config vertex written once at boot borrows one slot forever. Long-held vertices are the ones to leave on the sentinel, whatever the class.

Owner-declared application fields (settings.app)

The app key is reserved inside the vertex SETTINGS namespace (05 §0x0B): the protocol MUST never mint a QoS or machinery knob named app, and everything below settings.app. is owner-defined — names, nesting, and value bytes are the application’s, opaque to the runtime. This is the substrate for the device-private half of the field discipline (ADR-0021 rule 3: fields are standard and device-private, like ioctls — the protocol owns the addressing, the device owns the catalog of what each field accepts):

  • Declaration is owner-initiated and local (RFC-0010 §A.2): the owner installs, through the local host API, a per-vertex field descriptor table{ name, access {ro, rw, wo}, descriptor bytes, initial value? } per field. There is no wire operation that declares a field; a remote peer writes declared fields, never invents them. Undeclared names — under settings.app. and everywhere else — keep SCHEMA_NOT_FOUND (the ENOTTY default survives; the table opens only the holes the owner named).

  • Writes: the owner always reads and writes its own declared fields (ro/wo constrain remote callers). A caller-attributed write is admitted iff the caller holds the ordinary WRITE right on the vertex (otherwise tr::access::denied — evaluated first, before any name under settings.app. is resolved, so a denied caller cannot probe which owner names exist; RFC-0010 §Erratum 2026-08-12) — and the field is declared rw/wo (otherwise SCHEMA_NOT_FOUND, the answer every admitted caller gets, identically). The value is stored verbatim: the runtime performs no dtype/range validation against the descriptor (self-description for consumers; semantic validation is the owner’s, in its apply step). Field writes to a nonexistent vertex do not create it.

  • Reads: a declared field serves its stored TLV verbatim, gated by the vertex READ right. A wo field has no read surface (SCHEMA_NOT_FOUND — a secret never mirrors back). A declared field never written reads NOT_FOUND (distinct from undeclared) and is omitted from container reads. read <v>:settings.app serves the app container; read <v>:settings serves the full settings container — protocol knobs and the nested app record in one traversal.

  • Storage: a declared field’s value is a bare TLV riding the vertex — no subscriber list, no ACL slot, no vertex-map entry; cost = its bytes plus one table slot. A config datum that genuinely needs independent subscribers is promoted to a child vertex (field promotion, CONTEXT.md) — that trade is the schema author’s, per datum.

Change notification — the announce-write convention (RFC-0010 §C). A field write — protocol or app, local or remote — does not wake await, does not advance the vertex’s write sequence, and does not propagate along subscription edges: the property plane is silent by design. A property change consumers should notice is followed by an ordinary announce write at the vertex — the owner assigns and propagates once the change is actually applied (for a remote app-field write, in its apply handler; the graph never announces on the writer’s behalf). At a handler vertex the announce is a write, not an assign+propagate pair — that pair requires retention and refuses there (RFC-0008 Amendment 2). Subscribers receive one ordinary delivery and re-read the property tree if they care which knob moved. Consumers MUST NOT poll fields for change detection and MUST NOT expect per-field wakeups.

This is enforced, not merely stated (#585). An await carrying a :field selector answers ERROR{tr::schema::not_found} — the same ENOTTY a read or write of an unserved facet returns. It applies to every selector, including :subscribers and :acl, which read and write normally: the field is real, the await surface is not. Rejecting is what keeps a consumer that asks for a per-field wakeup from silently receiving a whole-vertex one — or a tr::flow::timeout it cannot distinguish from a quiet link.

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 this model defines elsewhere:

  • 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 treats now ts > tolerance as stale.

    • Producer liveness ⚠️ intended, not implemented, and no longer half-present: a deadline plus the read-only :liveness.last_seen_ns / :liveness.missed_deadlines fields (above) would say whether the producer is still writing within its contract, and a missed deadline would be the canonical “this vertex went stale” signal, observable without the producer doing anything. None of it exists. The :settings.deadline_ns knob that used to accept writes nothing enforced was removed by RFC-0022 §3.E — a writable knob no code reads is worse than an absent one, which at least answers honestly. If deadlines land later they arrive as a magnitude on the subscription, when something implements them. The staleness concept below stands on ts alone today.

  • Invalid / fault (the producer is alive but its value is meaningless right now) is a STATUS=ERROR(<reason>) written in place of a VALUE. 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 a STATUS (type 0x09) from a VALUE (type 0x01) by its type code and reacts (hold last-good, alarm, fail over) exactly as it would for a liveness fault.

The second bullet is a producer-side convention, not a runtime behaviour. A vertex stores whatever TLV is written to it and the graph never mints a STATUS on a producer’s behalf; a consumer must therefore not infer that a silent vertex is faulted, and must not assume every producer adopts the convention. A connection vertex’s link state, for one, is published as an ordinary VALUE carrying the state code, not as a STATUS.

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 branch POINT); the aggregate remains available as the composed branch read of the parent (RFC-0016). A newly appeared child surfaces as its first write bubbling up — every way a vertex comes into being is itself a write (a local write-create, a branch-write landing, a creator-endpoint create), so appearance is the first write; a child’s value change surfaces the same way. There is no separate :children_changed facet — that would duplicate what subtree delivery already does. (Wire-level concrete-path tagging of remote deliveries is 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 one composed read of the parent on join (RFC-0016 — values and topology in a single reply), then subscribe (to the parent) for the tail.

Appearance is observable; disappearance is not — see §Retirement notification below.

Vertex lifecycle: write-creates, retirement, revival

A vertex enters the graph two ways: explicit registration (an init-time or catalog-driven create — including the ADR-0059 creator endpoint, which is how a peer creates) or write-creates — a local data write to an unregistered path materializes its target, and any missing ancestors along the way (mkdir -p-style). Either way appearance is the first write (§Observing structural change above), because a creator-endpoint create is itself a write and bubbles the same. A remote data write to an unregistered path is tr::path::not_found and creates nothing (RFC-0005 §D amendment 1).

Leaving is retirement, not erasure (RFC-0009). Retiring a vertex re-virginizes the subtree in place: each vertex unwinds its subscriber contribution to its descendants, reverts to an unregistered placeholder, and its retired path then reads not_found (0x0020) — identical to never-existed, with no distinct retired status — no such code is allocated, and 0x0023 remains free. The vertex object is not freed — outstanding handles stay valid (an insert-only vertex lifetime, ADR-0057) — and the detached value-seam block is parked: the seam is read lock-free, so the retiring thread cannot free a block a reader may still hold. A vertex bears such a block iff a handler (on_read, on_write or on_children) was installed at registration — presence, not role: a STORED_VALUE vertex with an on_children parks one, a HANDLER vertex registered with no handlers parks none. The park’s other end is explicit and the embedder’s: graph_t::collect() frees everything parked, on the caller’s thread and outside every graph lock, at a point the caller knows no reader holds a seam; graph_t::parked_seam_count() makes an uncollected park observable (#576). Connection teardown retires the /net/<module>/<name> identity vertex — STORED_VALUE, and seam-bearing only when its link exposes a bus facet (link->bus() != nullptr: CAN, or a tcp/ws server wired peer_named = true). So a bus node with peer churn that never collects grows the park forever, while a point-to-point deployment (dial links, UDP, loopback, a default-wired server) parks nothing on teardown and needs no collect point. Graph teardown is a growth backstop, not the policy: the park member destructs last, after the vertex tree and the map lock, so a seam whose destructor re-enters the graph must be collected explicitly rather than left to teardown. The root cannot be retired, and retiring an already-retired or unregistered path is a no-op. There is no wire operation that reaches retirement directly: a peer removes by writing a request the device executes (ADR-0059 creator-endpoint), keeping removal owner-mediated.

A retired path may be revived by a later registration or local write-creates (a remote data write does not revive it, for the same reason it does not create it). The revived vertex inherits nothing of the retired owner — in particular it takes its live ancestor’s ACL policy, never the retired one’s, so a stale grant cannot outlive the retirement.

Subscriber-edge eviction is a distinct, lighter operation. When a link drops — a transport peer departs, a QUIC / WebTransport connection closes — the edges that fanned out over it are torn down without retiring their target vertices: evict_link_edges(link_name) clears every subscriber edge bound to that link (each vertex under its own stripe lock) and returns the count evicted, freeing the slots for reuse (RFC-0009 §D, extended to peer departure). “Bound to that link” means the link the edge was admitted over, not only the one it delivers over: a SUBSCRIBE op stores both (the edge’s return route rides that link), while an edge admitted by a remote :subscribers[N] write re-dispatches to a local target and stores the inbound link only as its ACL gate context — both are evicted by their session’s departure (#943). The vertices live on; only the dead delivery edges go. See 04-communication-flows.md §Liveness loss for the flow.

Retirement notification

A composite subscriber is never told that a child went away. On the live retired transition the vertex delivers nothing along any subscription edge: no tombstone record, no STATUS, no NOT_FOUND at the child’s path, no snapshot diff. The DELETE access-mask bit (05 §ACL) gates the right to remove; it does not define a notification, and no other mechanism supplies one. Enumeration agrees with the silence: a retired child MUST NOT appear in read(<parent>:children[]), exactly as a child that was never created.

The consequence is asymmetry. Appearance is observable, because whatever creates a vertex — a local write-create, a branch-write landing, a creator-endpoint create — is itself a write, and it bubbles to every subtree subscriber. Disappearance is observable only by polling — re-reading the parent’s member list, or reading the child’s path and finding not_found.

That answer is itself ambiguous. After retirement, tr::path::not_found carries three meanings a peer cannot tell apart:

  1. this path never existed;

  2. this path exists but was never written, so it has no last-known-value;

  3. this path existed and was deliberately retired.

The collapse is an accepted cost, not an oversight: minting a distinct status code obliges every implementation to distinguish states no implementation can yet produce, and pins a wire identity in a format aiming at immutability. Re-adding a code later is cheap; withdrawing one is not. A consumer that must distinguish (3) from (2) — a topology view, a device inventory, a UI that shows “gone” differently from “quiet” — has to carry its own expectation of what should exist, keyed by an identity it chooses; the graph never remembers on the client’s behalf.

Delivery of a removal is neither parked nor unspecified: no removal-push surface exists, normatively (#937, ruled 2026-08-12 — #66’s option 3, striking the deferral RFC-0005 §E carried). The silence stated above is the whole of the contract: a tombstone delta, a NOT_FOUND delivered at the child’s path, and a pushed snapshot diff were each considered and none exists (RFC-0009 §Alternatives; §B.5 forbids the mechanism outright). Anyone building a topology view over the graph model re-reads — enumerating :children[], or diffing a composed branch read (RFC-0016) — and carries its own expectation of what should exist, per the paragraph above. The ruling is revisited only when a real consumer (the #58 reconciler, or the SPA) demonstrates the need; because RFC-0009 §B.5 forbids the mechanism, any future push surface is an amendment (a new RFC).

Registration racing a concurrent operation

There is no stated ordering between a registration and a concurrent operation on the address being registered. Registration may happen at any time, concurrently with reads, writes and awaits on other addresses; for the same address an implementation is free to answer a racing read either way, and nothing lets a caller tell which answer it got. A caller that needs the answer must order the two itself. See 15-concurrency-and-scaling.md.

Module-namespaced extension fields

A transport module MAY add module-namespaced settings, e.g. :settings.transport_tcp.send_buf_kb. Rules:

  • Module fields MUST live under their own module name (here, transport_tcp).

  • The name app inside vertex SETTINGS is reserved for the application (RFC-0010 §A.1): no module or future protocol knob may claim it. The application is one more namespace owner under the same nesting shape; app is its name.

  • 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 :schema output 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 namespace is vertex-level: :settings.<module>.<field> is the resolving form should module fields land. ⚠️ No module-namespaced field is implemented. The runtime resolves exactly one subkey below settings — the reserved app (RFC-0010 §A) — and answers tr::schema::not_found for every other second step, on read and on write alike, caller-independently; the :schema machinery the rules above describe therefore has nothing to report, since the synthesized core part is empty by construction (§the :settings read above). A per-subscriber module setting has no wire surface either, because :subscribers is addressed whole (§The payload-discriminating :subscribers[N] write).

Limits the protocol does not impose

A vertex’s writable fields are whatever the schema says — for the application namespace, whatever the owner’s field descriptor table declares (RFC-0010 §B: the table is the app part of the schema, so the two cannot drift). 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 VALUE TLV’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 (/sensor/temp)

topic (/sensor/temp)

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 z_get etc.)

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 FWD hop)

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 FWD TLV (type 0x0F, 05-protocol-tlvs.md) wrapping the payload: FWD is structured (PL=1) carrying the op code, the dst route (the explicit source route to the target), the src route (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 leading dst mount run names a transport link):

  1. Strip the leading dst mount runnet/<module>/<name>[/<peer>] (RFC-0014 S2a), not one segment; the route shrinks toward the target.

  2. Prepend the node’s segment record for the inbound link to src — the return route grows.

  3. 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 leading dst segments name a local vertex):

  1. Decode the frame and apply the op to the local vertex.

  2. Shed the FWD envelope — the graph stores only the bare payload TLV, trailer-less at rest. No routing metadata lands in graph data.

  3. Reply with a fresh FWD{REPLY} whose dst is the accumulated src — the reply retraces the request’s route hop-by-hop.

Forwarding is loop-free by construction: dst is consumed monotonically per hop, so a delivery travels exactly as far as its explicit route and no further — a physical cycle is harmless per-op, not rejected. There is no revisit check, 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.)

Consequences

  • Graph reads are clean. A subscriber reading /net/can/can0/wheel/left gets 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 segment to src
    LB->>ESP: FWD{ WRITE, dst=/wheel/left, src=/can0/stm, VALUE }
    Note over ESP: leading dst segments are 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 names its hops explicitly and is consumed by at least one segment per hop (a whole mount run, RFC-0014 S2a), so even a route that re-enters a node it already crossed (/b/c/a/b/c/…) is finite: it simply terminates when the route is spent, delivering no further. No cycle can persist — loop-freedom is by construction, needing no revisit check.

This shedding rule is what keeps the global topology safe for any shape — see 07-host-embedding.md.


Pitfalls

An indexed :subscribers[N] write treated as an unconditional clear

Rule. An indexed write is discriminated by its payload: the empty-STATUS sentinel clears, a SUBSCRIBER replaces, anything else is TYPE_MISMATCH with the slot untouched.

Failure mode. An implementation that clears slot N whatever the payload is silently unsubscribes a third party and reports success. A client writing a SUBSCRIBER to :subscribers[3] to retarget record 3 destroys whoever held slot 3 and receives a RESULT byte-identical to a legitimate clear. Neither party can detect it: the writer’s reply says success, and the victim simply stops receiving.

A second route to the same failure. [*] sets both the wildcard and the indexed marker but never assigns an index. An implementation that tests “indexed” alone therefore reads the default index 0, clears slot 0, and answers RESULT. The WRITE grammar has no wildcard axis, so [*] on a write is a malformed address (INVALID_PATH), not a selector.

A third. Admitting a replace through the plain WRITE gate. A replace installs a new edge, so it must pass the SUBSCRIBE gate — the same door as an append. Routing it through WRITE alone lets a caller who may only write values install a delivery edge.

A multi-step selector under :subscribers

Rule. :subscribers is addressed whole. A SUBSCRIBER record is stored and served as one TLV, never member-wise, so :subscribers[N].<anything> names nothing and answers SCHEMA_NOT_FOUND — the same answer the read half gives. The shape is resolved before the ACL gate: a selector that names nothing is not an access question.

Failure mode. A field decoder that ignores the trailing steps falls into the indexed arm and destroys slot N on any mistyped tail — :subscribers[0].liveness.last_seen_ns, or a stray suffix — while answering RESULT. The caller wrote nothing, unbound a live subscriber, and was told it succeeded.

Corollary for module fields. Per-subscriber module settings such as :subscribers[N].settings.transport_tcp.send_buf_kb are not addressable and never were; the resolving form is the vertex-level :settings.<module>.<field> (§Module-namespaced extension fields).