Reference 03 — Addressing¶
Defines how vertices and fields are named, how a subscription observes a subtree, and how application-level slicing replaces wire-level fragmentation. See also: 04-communication-flows.md for API rationale; 02-graph-model.md for the schema discipline that gives field names meaning.
Path syntax¶
EBNF (using ABNF-like notation). There is one grammar: the concrete path form is used everywhere — as the argument to read / write / await and inside a SUBSCRIBER’s PATH child alike.
path = root [ segment *( segment-sep segment ) ] [ field-sep field-chain ]
root = "/"
segment-sep = "/"
field-sep = ":"
segment = name [ index ]
name = 1*64 ( UTF8-codepoint - reserved )
index = "[" ( 1*5DIGIT / "" ) "]"
field-chain = field *( "." field )
field = name [ index ]
reserved = "/" / ":" / "." / "[" / "]" / "*" / "?"
DIGIT = %x30-39
There is no wildcard grammar. Subscriptions do not need one: every subscription is a subtree subscription (RFC-0005) — subscribing to a vertex observes it and all of its descendants — so “everything under /sensor” is expressed by subscribing to /sensor itself, with no pattern syntax (see §subtree subscriptions below). A path containing * or ? anywhere MUST be rejected with ERROR{tr::path::invalid}.
All names are UTF-8, case-sensitive, case-folded NOT performed (Unicode normalization is the application’s responsibility —
/Sensor/tempand/sensor/tempare different paths).Maximum single-name length: 64 bytes (UTF-8 encoded).
Maximum total path length: 1024 bytes, measured as the encoded
PATHbody — the concatenated packed segment records, i.e. exactly thePATHTLV’slengthfield. Each segment therefore costs its 1-byte length prefix plus its UTF-8 bytes, not its bytes alone (RFC-0018; see the note on the cap’s unit below).Maximum segment depth: 255 (RFC-0023 — chosen from the wire’s own widths, superseding the inherited 32; the total-path byte cap above binds tighter whenever mean encoded segment cost exceeds 4 bytes, which under the packed record body means a mean segment longer than 3 bytes. Shorter than that, the 255 count binds — a real crossover, where the retired
NAME-child encoding cost4 + lenper segment and capped the depth at 204 so the count clause could never fire). (An addressing limit on PATH construction; the TLV parser itself has no depth cap — nesting is receiver-resource-bounded per RFC-0006.)Maximum field-chain depth: 8 (e.g.,
:settings.transport_tcp.tls.cipher.suiteis at the limit).Maximum index value: 65535 (fits in u16).
A path that violates any limit MUST be rejected with ERROR{tr::path::invalid}.
The cap’s unit is the encoded
PATHbody — and the unit is load-bearing. It is not the sum of segment bytes plus separators:path_t::parsecounts the accumulatedemit_path_segmentoutput, which includes the 1-byte length prefix per segment, so the number the cap bounds is exactly thePATHTLV’slengthfield. For 32 segments of 29 bytes that is 960, against 959 for bytes-plus-separators — one byte apart here, and further apart the deeper the path, so a path one reading calls conforming another rejects. (Under the retiredNAME-child body the same path encoded to 1056, which is why this note exists at all and why the reading matters more than the arithmetic: RFC-0018 moved the number without moving the rule. A packed body happens to be the same length as the string form/a/b/c, a length prefix costing exactly what a separator does — a coincidence of this encoding, not a definition; the cap is still stated over the encoded body.) The encoded-body reading is the one C++ and Rust both implement. See RFC-0019 §4.3, which depends on this unit being unambiguous.
Examples¶
/sensor/temp — a vertex
/sensor/temp:subscribers[0] — a control field on a vertex
/sensor/temp:subscribers[] — append-or-list view of subscribers
/sensor/temp:settings.app.setpoint — a nested control field (owner-declared)
/sensor/temp:settings.transport_tcp.send_buf_kb — module-namespaced field (⚠️ grammar only, see below)
/net/can/can0/wheel-encoder/left — a remote vertex, routed through a transport-vertex
(the mount is two segments: module, then connection NAME)
/camera/frame/7 — an indexed child endpoint (one child vertex per index)
/i2c-bus/0x68/accel — peripheral on I²C bus 0x68
/ — the root vertex (rarely addressed directly)
⚠️ The module-namespaced line is grammar, not a reachable field.
:settings.<module>.<field>parses and is the resolving form should module fields land, but no module-namespaced field is implemented: the runtime resolves only the reservedappsubkey belowsettingsand answerstr::schema::not_foundfor every other second step, on read and on write, caller-independently (02-graph-model.md §module field namespacing).
Index forms¶
An index appears in two places, and they are different planes:
On a field step (
:name[N],:name[],:name[*]) — resolved against the vertex’s field schema, and carried on the wire by the FIELD level’sindex_modebyte (§the index form on the wire).On an address segment — the grammar reserves the form (
segment = name [ index ], withindexsitting outsidename), but v1 assigns it no wire carrier: a PATH TLV’s body is packed segment records with no per-segment type or option byte (05-protocol-tlvs.md §0x06), a segment must not contain[or](§Reserved characters below), and there is no separate index field. An address-segment index encoding, if one ever lands, travels outside the segment bytes and needs an RFC amendment (#996 ruling; cf. the declined range-slice proposal in.out-of-scope/range-slice-addressing.md). The interoperable v1 construction for indexed endpoints is one registered child vertex per concrete index —/camera/frame/7— which also makes a subtree subscription on/camera/frameobserve every index (§pitfalls).
On a field step:
[N](decimal integer 0..65535): a specific slot.[](empty index): the array as a whole. A read returns aPL=1reply whose children are the element TLVs; a write appends to the next free slot.[*]: every slot. Legal only on a subscriber path — see §the index form on the wire and §pitfalls.
Field indexing is resolved at L4 from the field schema, not from the storage layout: a fixed-stride array (uniform element size) resolves [N] by direct offset (O(1)) on contiguous backing; otherwise the children are walked (ADR-0008).
The index form on the wire¶
The text spelling above is the human form. A remote operation carries the field
chain as a 0x10 FIELD TLV, one level per .-separated step, and each
level spells its index form with an optional trailing 1-byte index_mode VALUE
(RFC-0004 §C):
Text |
FIELD level |
|
|---|---|---|
|
|
absent ⇒ |
|
|
|
|
|
|
|
|
|
index_mode is optional and defaults to SCALAR, so :name and :name[N]
each have one canonical spelling while the mode byte is what separates [] from
[*]. A mode byte outside {0, 1, 2} is malformed and MUST be rejected with
ERROR{tr::path::invalid}.
Two consequences:
:name[]and:name[*]differ by exactly one byte, and a decoder that reads only the u32 index renders:name,:name[]and:name[*]identically. The conformance corpus pins all three (field/field-scalar,field/field-append,field/field-wildcard).[*]is a wire marker, not a path character. The reserved-character rule below still forbids*inside a NAME, and there is still no textual wildcard grammar.
Reserved characters¶
The five characters / : . [ ] plus * and ? cannot appear inside a NAME segment. Implementations MUST reject any NAME containing them with ERROR{tr::path::invalid}.
(* and ? are not path characters in v1; they are reserved to keep the door open for a possible future per-segment wildcard grammar — see §per-segment wildcards.)
All three tiers enforce the full seven-character set through one predicate each — C++ tr::graph::valid_segment, Rust validate_segment, TS RESERVED_SEGMENT_CHARS — and the set is pinned cross-tier by the path/path-reserved-brackets conformance vector plus each tier’s own host test (see tests/conformance/HARNESS.md). Until #996 the C++ core admitted [ and ] on the theory that an address index travels inside the NAME bytes; the ruling went the other way — the grammar’s index sits outside name, and an address-index encoding, if one ever lands, stays outside the NAME bytes (§index forms above).
Field-path resolution¶
The : separator divides a path into the vertex address (left of :) and the field chain (right of :).
/sensor/temp:settings.app.setpoint
└────┬────┘└──────────┬──────────┘
vertex addr field chain
Resolution proceeds in two stages:
Resolve the vertex address by walking the segment chain from the root. Each segment must match a child vertex name; index segments select indexed children.
Resolve the field chain against the vertex’s schema (read
:schemato enumerate). Each.subfieldstep descends one level;[N]selects a slot in an array-typed field.
If stage 1 fails: ERROR{tr::path::not_found}. If stage 2 fails: ERROR{tr::schema::not_found} for an unknown field name; ERROR{tr::path::not_found} for an out-of-range index on an existing array field.
Reading vs writing array slots¶
read("/x:subscribers[0]")returns the SUBSCRIBER TLV at slot 0, orSTATUS=ERROR(NOT_FOUND)if empty.read("/x:subscribers[]")returns aPL=1reply whose children are all populated SUBSCRIBER slots, in slot-order.write("/x:subscribers[3]", …)is resolved by its payload (RFC-0009 §D.1): an emptySTATUSclears slot 3, aSUBSCRIBERreplaces its edge, anything else isTYPE_MISMATCH.:subscribers[*]on a write isINVALID_PATH. See 02-graph-model.md §the payload-discriminating:subscribers[N]write.write("/x:subscribers[]", tlv)allocates the next free slot and places the TLV there. The caller can recover the chosen index by reading:subscribers[]and looking for their TLV (typically by including a unique subscriber-id NAME in the SUBSCRIBER record).
Atomicity of multi-field writes¶
A single write(path, tlv) is atomic: a concurrent reader sees either the full prior state or the full new state at that path, not a partial mixture. There is no atomic multi-field settings write: settings writes are per-field, and a bare :settings write resolves nothing (SCHEMA_NOT_FOUND) — see 05-protocol-tlvs.md §0x0B. Reads are the atomic direction: read /x:settings serves the whole container in one traversal. An application that needs several knobs to take effect together packs them into one settings.app.* leaf and writes that leaf.
// See the graph module: ../modules/graph.md
tr::graph::graph_t g;
// Non-atomic (a reader between the calls sees a mixture):
g.write(tr::graph::path_t("/x:settings.app.kp"), kp_value);
g.write(tr::graph::path_t("/x:settings.app.ki"), ki_value);
// An atomic multi-field settings WRITE is not implemented: writes are per-field, and a bare
// `:settings` write resolves nothing (SCHEMA_NOT_FOUND). Reads ARE atomic — `read /x:settings`
// serves the whole container in one traversal.
//
// The flat `:settings.<knob>` core namespace is EMPTY since RFC-0022 §3.B: every name in it
// answers SCHEMA_NOT_FOUND. Only `settings.app.*` is writable below `settings`.
Subtree subscriptions (no wildcards)¶
Every subscription is a subtree subscription (RFC-0005, accepted and implemented): a SUBSCRIBER edge on vertex V observes writes to V and to every descendant of V — a leaf subscription is just the trivial case. A write at vertex W therefore delivers, once per subscriber, to the subscribers of W and of each of W’s ancestors (“vertical bubbling”). The delivered payload is the written TLV as-is — the exact frame the producer wrote, at the granularity it chose.
This covers the dominant “everything under a prefix” use case with no pattern grammar at all: subscribing to /sensor is what a /sensor/** wildcard would have meant, and subscribing to /camera/frame observes every indexed child /camera/frame/0, /camera/frame/1, … The full semantics — bubbling, branch-write decomposition, write-creates, and the near-free-when-idle cost model (an unobserved write takes no vertex lock and decides in two atomic loads) — are specified in 02-graph-model.md §subtree subscriptions and 05-protocol-tlvs.md §0x04.
Subscriber identity across a subtree¶
A subtree subscriber receives TLVs produced at many concrete paths and may need to know which vertex produced each. Provenance travels in the data where the application needs it (CONTEXT.md §SUBSCRIBER direction); for local delivery the concrete path is additionally available out-of-band (implementation-defined — typically a callback argument). Wire-level concrete-path tagging of remote deliveries is the separate, still-draft RFC-0003 proposal; without it, cross-implementation remote provenance beyond what the data carries is not guaranteed interoperable.
Per-segment wildcards (unratified)¶
A per-segment wildcard grammar — * matching one segment (/sensor/*/temp for horizontal matching across siblings) — is an unratified idea, not part of v1: no implementation exists, and adopting it would require its own RFC. The characters * and ? are reserved so such a grammar could be added without breaking existing names. Subtree subscription deliberately removes the need for the **-style vertical wildcard. This paragraph is non-normative.
The field-index [*] is a different thing and is not a future direction: it
exists in v1 as FIELD index_mode = WILDCARD (§the index form on the
wire). It is confined to subscriber-path targets —
a [*] level on any other field answers ERROR{tr::path::invalid}, which the
fwd/fwd-wildcard-reject conformance vector pins.
Address-shift slicing (replaces wire-level fragmentation)¶
The wire format (01-data-format.md) deliberately omits fragmentation rules. The application-level mechanism is address-shift slicing: a logically large payload is split across N child endpoints with the same timestamp.
Sender behavior¶
Logical message: 10 MB camera frame, timestamp T.
Publisher chooses slice size S = 64 KiB.
Number of slices N = ceil(10 MB / S) = 160.
For i in 0..159:
write("/camera/frame/<i>", VALUE{ts=T, bytes=slice_i})
Each slice is a complete, valid, independently-routable TLV. The publisher emits N writes; the router and transport see N separate dispatches.
Receiver behavior¶
A subscriber registers once on the parent vertex — a subtree subscription (RFC-0005) observes every indexed child:
write("/camera/frame:subscribers[]", SUBSCRIBER{path=/local/handler, settings})
Each subsequent write("/camera/frame/<i>", ...) bubbles to the parent’s subscription and produces a delivery to /local/handler, with the slice index recoverable from the producing path (out-of-band for local delivery; on-wire tagging for remote delivery is the draft RFC-0003).
The slice-group key¶
Slices assemble into a coherent group keyed by (origin, ts), with each slice’s index giving its position within the logical message.
ts is the slice TLV’s optional timestamp (opt.TS), a per-producer monotonic value rather than literal wall-clock: strictly increasing per origin, never regressing or colliding, so it identifies a group within one origin without a sequence number.
origin is a receiver-side identity, not a wire field. v1 defines no delivery-borne producer identity. The 0x0D ROUTER envelope that would have carried an origin_peer_id is a reserved codepoint with no mechanism — senders MUST NOT emit it (05-protocol-tlvs.md §0x0D) — so an implementation MUST NOT build the group key from a field it will never receive. A receiver derives origin from how the slice arrived:
Delivery |
What identifies the origin |
|---|---|
Local |
The producing vertex path, available out-of-band. |
Remote, full-route |
The accumulated |
Remote, compacted |
The per-link |
Header-elided (CAN) |
The link-local peer identity the transport derives from the frame id (14-can-transport.md). |
Grouping by ts alone is wrong: two publishers that happen to emit at the same timestamp merge into one group.
Two properties follow from a route-derived origin:
One producer reached over two links is two routed addresses, so it yields two group keys. That is the same deliberate redundancy that makes parallel links two subscriptions rather than auto-multipath (§routed scope); an assembler that must fuse them needs an application-level identity.
A route that changes — a reconnect that renumbers a link — changes the key. An application that needs a route-independent origin reads the producer’s node-scoped
:identityfacet once through the route and pins the mapping (RFC-0011).:identityis a readable field, not delivery metadata; it is never carried per slice.
(ADR-0011 spells the key (origin_peer_id, ts), naming the ROUTER envelope’s field. The pair is the same; the origin half is derived as above.)
Two further grouping rules:
A slice may arrive at any time within the deadline window.
Loss of an interior slice is detected as a missing index at deadline; loss of trailing slice(s) is detectable only when the group total is known (see §loss detection).
Subscriber assembly policies¶
The subscriber’s QoS at :settings.address_shift.* controls assembly behavior. (Field names are
defined here as the v1 design; none of them is implemented, and the two knobs this table used
to borrow from the core namespace — deadline_ns and queue_max_bytes — no longer exist, having
been removed as inert by RFC-0022 §3.E. When assembly lands, its deadline and its bound are
its own module-namespaced magnitudes under address_shift., not core knobs.)
Field |
Type |
Default |
Effect |
|---|---|---|---|
|
bool |
false |
If true, hold slices in a per-timestamp buffer until the group is complete or the deadline expires; deliver one assembled message. If false, deliver each slice immediately as it arrives. |
|
u32 |
0 (unknown) |
If non-zero, declares N up-front; missing indices are detectable before the deadline. |
|
enum |
|
|
|
u64 |
unset |
Per-group assembly deadline. After the deadline relative to the first observed slice, the group is finalized per |
Loss detection¶
Missing index k in a group with expected_count = N and observed indices {0..N-1} \ {k}: at deadline, the assembler emits STATUS=ADDRESS_SHIFT_GAP with ERROR.detail = k.
Group totality is opt-in. For groups without expected_count, the assembler treats the largest-observed-index + 1 as the implicit N at deadline — so a dropped trailing slice is invisible (a 100-slice group missing index 99 looks complete at slice 98). v1 does not force a count: open-ended streams cannot always supply one. If guaranteed tail-loss detection is required, the publisher MUST declare totality — either set expected_count, or precede the group with a :manifest write carrying the index set as a structured (opt.PL=1) TLV. (An end-of-group marker on the final slice is a possible future mechanism — see ADR-0011.)
Consequences¶
Lossless transport composition. Whatever the transport does (drop a UDP datagram, lose a CAN frame), each slice is independently lost or delivered. No reassembly state to corrupt.
No special FRAGMENT type code. The wire format from 01-data-format.md doesn’t need a fragment-with-reassembly-metadata type; the addressing scheme carries it.
Stream processing is natural. The subscriber decides whether to assemble or to process as a stream; the publisher doesn’t impose either choice.
Per-slice priority and QoS. The addressing scheme lets a publisher tag different slices with different priorities (e.g., camera I-frames at high priority, P-frames at low) by writing them to differently-configured
ep[N]slots.
Costs and obligations¶
Index allocation discipline. The publisher must agree with subscribers on what
[N]means (byte offset / slice_size? row index? sample index in a window?). This is an application-layer convention; libtracer does not impose semantics.Bubbling fan-out cost. Every slice write walks the ancestor chain when a subscriber exists at or above it — near-free when idle, but a hot high-rate slice stream pays one delivery per covering subscription (RFC-0005 §A cost model).
Address scopes: local, routed, global¶
The same path can resolve differently depending on which node evaluates it. The protocol distinguishes three scopes:
Local scope¶
A path resolves within the host’s own graph. No route prefix. Applies to:
In-process publishers and subscribers on the same node.
Vertex paths created by application code on this node.
Module-exported vertex paths (e.g.,
transport_i2cexposing/i2c-bus/0x68/accel).
Routed scope (path-as-route)¶
A remote vertex is reached by walking through a transport-vertex (ADR-0027 / CONTEXT.md §Path-as-route): the path /net/<module>/<name>/<remote path> — e.g. /net/can/can0/sensor/wheel/left — is the local address of the remote vertex. The connection mount is two segments, a module segment grouping one transport kind and role plus the connection’s own name (13-network-formation.md); a one-segment /net/<name> addresses a module vertex that does not exist and fails to resolve, and the path is the route. The prefix is the transport-vertex’s own path, not a configured string; the send-side suffix and the receive-side prefix are the same address.
The operation travels as an FWD frame carrying its own route: each forwarder hop strips the whole leading mount run — net/<module>/<name>[/<peer>] (RFC-0014 S2a) — from dst and prepends the inbound link’s own mount run to src, so dst is always the remaining forward route and src the accumulated return route. Explicit source routes cannot loop by construction — dst shrinks monotonically per hop, so a delivery travels exactly as far as its explicit route and no further; a cycle in the physical topology is harmless per-op (the route is finite, so the walk is finite). There is no visited-set or revisit check — loop-freedom is protection-by-construction, not protection-by-rejection. Nothing is republished at a fixed prefix; a consumer addresses the routed path directly, and deliveries return along the accumulated route. See reference/13.
Two links to the same peer are two different routed addresses (e.g. /net/ws/ws0/... and /net/can/can0/...) — deliberate redundancy the consumer subscribes to explicitly, not auto-multipath.
Global scope¶
The “global” scope is the union of all hosts’ local + routed graphs. There is no single authority that owns it; it is a logical view assembled by composing routes through transport-vertices.
A common convention (not normative): a peer’s data is addressed through the connection that reaches it — /net/<module>/<name>/... — and multi-hop reach composes one link mount per hop. This keeps the global graph navigable without name collisions.
Collision rules¶
When two registrations would claim the same local path:
First-binder wins: the first registrant to bind a vertex name owns it. Subsequent attempts return
ERROR{tr::path::in_use}(0x0022— 05-protocol-tlvs.md §error codes).Configuration avoids collisions by giving each link a distinct connection NAME within its module (
/net/can/can0,/net/ws/ws0).For routed addresses, uniqueness comes from the connection-NAME namespace of each node along the route. Conflicting peer identities on the network are a discovery-layer problem, not an addressing problem.
Path canonicalization¶
Two textually-different paths that name the same vertex MUST canonicalize to the same internal representation:
Trailing slashes:
/sensor/temp/and/sensor/tempare the same. Implementations SHOULD strip trailing slashes during parse.Empty segments:
/sensor//tempis invalid, not equivalent to/sensor/temp. Reject withERROR{tr::path::invalid}.The root path is exactly
/.//and beyond are invalid.
Field paths do not have a trailing-separator equivalent; :settings. (trailing dot) is invalid.
UTF-8 normalization: implementations MAY normalize path bytes to NFC at the parse boundary, but MUST be consistent: normalized paths and pre-normalized paths from peers must round-trip without collision. The recommended choice is to NOT normalize and to require senders to canonicalize before transmission. (Application authors generally use ASCII-only path components, so this is rarely an issue in practice.)
The two path forms¶
An address has two normative spellings, and everything above this heading describes the first.
Canonical |
Bound |
|
|---|---|---|
what it spells |
names, from the caller’s own root |
resolutions — one element per host on the route |
who can read it |
any peer, including one that has never spoken to you |
only the host that minted each element |
what it costs |
|
|
when it works |
always |
until the vertex it names is retired |
Canonical is the mint key and the fallback. A bound path is derived from a canonical one and never from anything else, which is what makes three things true at once: no address is reachable only in bound form; a failed bound path always has somewhere to fall back to; and a bound path can be re-minted from the address its holder still has. Canonical support is therefore mandatory and bound support is optional — to emit and to accept alike.
Everything this document specifies — the segment grammar, the 64-byte name limit, the 255-segment and 1024-byte caps, canonicalization, the reserved characters — governs the canonical form alone. A PATH_REF carries no names, so none of it applies to one; its own bound is a host count, derived in RFC-0024 §4.3. The byte layout and the routing semantics are in 05-protocol-tlvs.md §0x14.
A bound element is an address, never a capability: an operation arriving on one is authorized by the same per-operation access check at the target vertex that the canonical form performs, so a bound path can reach nothing its holder could not reach by name.
The label arm — a compressed spelling of the canonical form, not a third form¶
There are still two forms. A path label (RFC-0027, accepted 2026-08-15, implemented) compresses one hop’s local part of a canonical address in place: the PATH stays a 0x06, its other elements are untouched, and only the run of names one host resolves is replaced by that host’s 32-bit alias for the resolution it already made. That is what separates it from a bound path, which replaces the whole address with one element per host.
Canonical |
Bound |
Path label — an element inside |
|
|---|---|---|---|
granularity |
one element per segment |
one element per host, replacing the address |
one element per hop’s whole local part, in place |
who can read it |
any peer |
only the host that minted each element |
only the host that minted that element |
what it costs |
|
|
7 bytes for the part it replaces |
how it is asked for |
— |
an |
not at all: a hop rewrites its own part of a reply it was relaying anyway |
when it works |
always |
until the vertex it names is retired |
until the slot’s generation moves |
Canonical is the mint key and the fallback here too, for the identical reason: a label is minted from a canonical resolution and from nothing else, so no address is ever reachable in labelled form alone, a refused label always has a string original to fall back to, and the next reply re-mints. A labelled PATH is therefore never admissible as a canonical key — not as a vertex-map lookup key, not as an ADVERTISE route, not as a pre-encoded path handle — which is what keeps one spelling per address and keeps byte-prefix-implies-ancestor true.
Everything this document specifies about the canonical grammar governs the name elements of a path and not the label ones: a label element carries no segment, so the 64-byte name limit, the reserved characters and canonicalization do not apply to it, while the 1024-byte body cap does, since it is measured on the encoded PATH body. The byte layout and the routing semantics — mint on the reply, NOT_FOUND and string fallback on a stale one, no withdraw and no aging — are in 05-protocol-tlvs.md §0x06 §path label element. Like a bound element, a path label is an address, never a capability: every labelled operation re-checks access at the dereferenced vertex, exactly as the string spelling does.
Static path handles (MCU-friendly addressing)¶
Normative reference: ../spec/v1.md §3.1. See also: 05-protocol-tlvs.md §
0x06PATH for byte-precise PATH TLV layout.
The string form "/sensor/temp" is convenient at the API surface but hostile to the hot path on MCU-class hardware: it forces a parser walk, allocates segment structures per call, and pulls in snprintf (a few KB of code) when the path includes runtime indices. libtracer addresses this with a static path handle: a path is encoded into a PATH TLV exactly once — at build time or at node-init — and every subsequent reference uses the pre-encoded bytes directly.
The contract. A path handle is whatever opaque token an implementation hands back from path registration. It MUST resolve to wire bytes byte-equal to the canonical PATH TLV for the named vertex, and the resolution MUST NOT allocate, parse, or format strings on the hot path.
Path lifecycle¶
Three modes, in order of preference for embedded targets:
Mode |
Where the PATH TLV lives |
When the bytes are produced |
Hot path cost |
|---|---|---|---|
Build-time literal |
|
At compile time (macro or codegen emits the byte literal) |
Pointer-load — zero runtime work |
Init-time registration |
RAM (long-lived segment) |
Once at node init ( |
Pointer-load |
String at hot path (string-parsed, convenience) |
RAM (short-lived) |
On every call |
Parse + alloc + canonicalize |
The string-at-hot-path mode is NOT required of conforming implementations and a minimum-feature (P0) build MAY omit the string entry points entirely.
Static path construction and use¶
flowchart LR
subgraph Build["Build time"]
S["Source path string<br/>"/sensor/temp""]
M["path_t("/sensor/temp")<br/>parse-once ctor / codegen"]
R[".rodata bytes:<br/>06 00 0C 00 ... 06 "sensor" ... 04 "temp""]
S --> M --> R
end
subgraph Init["Node init (once)"]
I["path_t::parse(string)"]
H["heap segment with same bytes"]
I --> H
end
subgraph Hot["Hot path (per write)"]
HND["path handle<br/>(&rodata or &heap)"]
W["g.write(handle, value)"]
DISP["router dispatch<br/>(byte-compare on PATH bytes)"]
HND --> W --> DISP
end
R -.holds bytes for.-> HND
H -.holds bytes for.-> HND
Both paths land in the same shape: a const region whose bytes are a valid PATH TLV. The hot-path API treats them identically.
Parse-once path construction¶
An implementation encodes a literal path exactly once at construction, and the resulting handle is reused on every subsequent write; the reference implementation spells that as the infallible parse-once path_t("...") constructor (ADR-0054). A binding may additionally expose a build-time / consteval PATH encoder; the wire bytes are identical. The C++ sketch below shows the reference shape (see the graph module and the view module):
tr::graph::graph_t g;
// Parse-once handle: the PATH TLV is encoded a single time here.
// Reserved-char / length validation happens in the constructor.
tr::graph::vertex_handle_t sensor_temp =
g.register_vertex(tr::graph::path_t("/sensor/temp"),
tr::graph::role_t::STORED_VALUE);
// Build a fresh VALUE view over f32 bytes (standard helper pattern).
tr::view::view_t value_f32(float f) {
tr::view::segment_ptr_t seg = tr::view::heap_alloc(4);
std::uint32_t bits;
std::memcpy(&bits, &f, 4);
for (int i = 0; i < 4; ++i)
seg->bytes[i] = static_cast<std::byte>((bits >> (8 * i)) & 0xFF);
return tr::view::view_t::over(std::move(seg));
}
// Hot path — write by handle, no path parsing.
void on_sample(float t) {
g.write(sensor_temp, value_f32(t));
}
Encoding the literal once walks the path, rejects reserved characters, counts segments, and emits the byte sequence:
06 PL=0+CR=0 LL=0 length=u16 | type, opt, length (06 00 0C 00)
06 's' 'e' 'n' 's' 'o' 'r' ← record: len 6, "sensor" (7 bytes)
04 't' 'e' 'm' 'p' ← record: len 4, "temp" (5 bytes)
Since .rodata is read-only, the bytes are never modified. The router’s dispatch table indexes by byte-equality on the PATH TLV’s payload, so two TLVs that name the same vertex hash and compare identically regardless of where their bytes live (flash, heap, or transport receive buffer).
Init-time registration for runtime-derived paths¶
Some paths are not known at compile time:
Connection-routed paths (
/net/<conn>/sensor/temp) — the connection name is established at runtime.Address-shift slice paths (
/camera/frame/0,/camera/frame/1, …) — the index varies per slice.
For these, register each concrete indexed path once at init and keep its vertex handle. Runtime strings are parsed with a fallible entry point (path_t::parse, returning std::expected); literal indexed paths use the path_t("...") constructor directly:
tr::graph::graph_t g;
// Validate, canonicalize, encode once. The vertex handle is stable for node lifetime.
std::vector<tr::graph::vertex_handle_t> frame_slice;
frame_slice.reserve(N);
for (std::size_t i = 0; i < N; ++i) {
// Runtime-derived index → path_t::parse returns std::expected; deref on success.
auto p = tr::graph::path_t::parse("/camera/frame/" + std::to_string(i));
frame_slice.push_back(g.register_vertex(*p, tr::graph::role_t::STREAM));
}
// Hot path — zero-copy borrow of the DMA buffer, no string work.
void on_dma_complete(std::byte* frame, std::uint64_t /*ts*/) {
for (std::size_t i = 0; i < N; ++i) {
tr::view::view_t slice =
tr::view::view_t::over(tr::view::borrow(std::span<std::byte>{frame + i * S, S}));
g.write(frame_slice[i], slice);
}
}
Registration encodes exactly one PATH TLV in a long-lived segment, validates it against §path syntax, and returns the handle. After init, the handle behaves identically to a build-time literal: a pointer-load and a dispatch.
Indexed slot paths¶
For the common case of an index i ranging over a known set, register each real indexed child path (/camera/frame/0, /camera/frame/1, …) once and write by its handle:
tr::graph::graph_t g;
// One vertex per real indexed path.
std::vector<tr::graph::vertex_handle_t> frame;
frame.push_back(g.register_vertex(tr::graph::path_t("/camera/frame/0"), tr::graph::role_t::STREAM));
frame.push_back(g.register_vertex(tr::graph::path_t("/camera/frame/1"), tr::graph::role_t::STREAM));
// …
void on_dma_complete(/* … */) {
for (std::size_t i = 0; i < N; ++i) {
g.write(frame[i], slice_view(i));
}
}
A single-PATH-plus-index form — encoding /camera/frame once and supplying i as a separate u16 at the dispatch boundary — is a permitted-but-not-implemented optimization (non-normative): the reference core has no separate indexed-handle API. It would be equivalent to the real write to /camera/frame/<i> above — the resolved vertex and the wire bytes (after index expansion) are identical.
Hot-path dispatch with a static handle¶
sequenceDiagram
participant App as Application (ISR / sample loop)
participant Hnd as Path handle (.rodata)
participant Disp as Router dispatch
participant Vtx as Vertex
participant Subs as Subscribers
App->>Hnd: load pointer (1 cycle on Cortex-M)
App->>Disp: g.write(handle, value)
Disp->>Disp: dispatch_table[hash(handle.bytes)]
Note over Disp: byte-compare on PATH bytes<br/>no string parse, no alloc
Disp->>Vtx: store value_tlv as LKV
Disp->>Subs: refcount-bump and enqueue (per subscriber)
The boxed note is the load-bearing one: dispatch never re-parses the path. The handle’s bytes are the cache key.
Effects on a constrained target¶
Code size. Removing
snprintffrom the publisher saves 2–6 KB on Cortex-M, depending on libc. For a 16 KB target (10-module-catalog.md §profile sentinel), this is the difference between fitting and not fitting.Determinism. No allocation on the hot path means no fragmentation, no malloc-under-ISR, predictable worst-case latency.
Cache behavior. Build-time PATH TLVs live in flash and are streamed via XIP / cached I-side accesses; they never compete with the data cache.
Wire correctness by construction. Validation is done once at encode time; the hot path can assume the handle’s bytes are a valid PATH TLV. There is no class of “malformed path on the hot path” bug to worry about.
Conformance summary¶
A conforming node:
MUST accept path handles at every read / write / await entry point (../spec/v1.md §3.1.4).
MUST treat a path handle and the equivalent string-form path as semantically identical.
SHOULD provide a build-time encoding macro for paths known at compile time.
MAY omit string-form entry points entirely on minimum-feature builds.
MUST NOT require the application to format paths on the hot path.
The full byte layout of the encoded PATH TLV is in 05-protocol-tlvs.md §0x06. The init-time vs hot-path distinction is in 04-communication-flows.md §the static-path write flow.
Pitfalls¶
Each entry states the rule, then the shape of the failure an implementation produces when it gets the rule wrong.
[*] treated as [0]¶
Rule. A [*] level is FIELD index_mode = WILDCARD, and it is legal only where the field chain’s first step is subscribers. A [*] level on any other field answers ERROR{tr::path::invalid}. On a write it answers ERROR{tr::path::invalid} in every case: the write grammar has no wildcard axis.
Failure mode. [*] marks the level as indexed while carrying no index, so the index stays at its default 0. An implementation that branches on “is this level indexed?” without also testing the mode resolves :subscribers[*] as :subscribers[0] — it clears or replaces slot 0, silently evicting a third party’s subscription, and answers RESULT. The damage is a success report, not an error.
Checks. fwd/fwd-wildcard-reject pins the resolution-layer rejection of [*] on a non-subscriber target: the frame is round-trip-valid at the codec layer, so a codec-only conformance run passes it, and only a resolver test catches the defect. field/field-wildcard pins the addressing spelling.
:name, :name[] and :name[*] read as one form¶
Rule. The trailing index_mode byte is the only thing that separates them: absent ⇒ SCALAR, ELEMENT with no index ⇒ [], WILDCARD ⇒ [*].
Failure mode. A decoder that reads only the u32 index — or that ignores a level’s trailing VALUEs — renders all three identically, so “append one subscriber” and “address every slot” become the same operation. field/field-scalar, field/field-append and field/field-wildcard are the same shape apart from that byte; a decoder that passes one and not the others has this defect.
:subscribers[N] written payload-blind¶
Rule. A write to :subscribers[N] is resolved by its payload (RFC-0009 §D.1): an empty STATUS clears the slot, a SUBSCRIBER replaces the edge, anything else is TYPE_MISMATCH.
Failure mode. An implementation that treats [N] as unconditional-clear destroys the edge a peer wrote a SUBSCRIBER to replace, and reports success. Both this and the [*] case above fail the same way — a third party is unsubscribed and nobody is told. The graph-model side of the same rule is in 02-graph-model.md §the payload-discriminating :subscribers[N] write.
The wire index is u32; the addressing limit is 65535¶
Rule. A [N] level carries its index as a VALUE u32, while §path syntax caps an index at 65535.
Failure mode. Narrowing the decoded u32 to 16 bits without a range check aliases [65536] onto [0], [65537] onto [1], and so on — a write lands in the wrong slot with no diagnostic. Reject an index above the limit with ERROR{tr::path::invalid} at the point the address is admitted, not at decode.
A segment index read as a field index¶
Rule. The two index planes are not interchangeable (§index forms). A field index travels as a FIELD level’s index_mode. A segment index has no carrier at all in v1: a PATH TLV’s body is packed segment records (05-protocol-tlvs.md §0x06) and a segment must not contain [ or ] (§Reserved characters), so a bracketed segment such as /camera/frame[7] is rejected with ERROR{tr::path::invalid} at every minting boundary (#996).
Failure mode. An implementation that invents a resolution rule below /camera/frame for the [7] will not interoperate: no conformance vector under path/ carries a bracketed segment, so nothing pins the byte spelling, and giving [n] a value-plane meaning is the subject of a draft proposal (RFC-0017) rather than settled v1. The interoperable construction is one registered vertex per concrete index (§indexed slot paths).
A slice group keyed on a field the wire does not carry¶
Rule. The group key is (origin, ts) where origin is derived from how the delivery arrived (§the slice-group key), never from a producer-identity field in the frame.
Failure mode. An implementation that reads an origin_peer_id out of a 0x0D ROUTER envelope receives no such envelope — senders MUST NOT emit 0x0D — so every slice keys on a zero origin, and slices from every publisher with a colliding ts merge into one corrupt group. The symptom is an assembled message whose interior is another producer’s bytes, not a gap.