path — addressing (L4)

In one paragraph

A path_t parses /sensor/temp (with an optional :field.sub[N] tail) into the canonical PATH-TLV payload bytes — the concatenated packed segment records. Those bytes, not the string, are the vertex-map key: dispatch is a byte compare, never a string parse on the hot path.

What it does

path_t::parse validates and canonicalizes per the addressing rules (reference/03): strip a trailing /, reject empty segments (//) and unrooted paths, enforce the limits (≤64 B/segment, ≤1024 B total, ≤255 segments, ≤8 field steps). It emits the canonical key — e.g. /sensor/temp06 'sensor' 04 'temp' (12 bytes; each record is [u8 len][utf8], RFC-0018) — and parses the :-tail into a field_path_t (settings.app.setpoint, subscribers[], subscribers[3]) for the field-write surface. path_key_t + path_key_hash_t (FNV-1a over the bytes) key the unordered_map.

Interface

struct field_step_t { std::string name; bool indexed, append, wildcard; std::uint16_t index; };
struct field_path_t { std::vector<field_step_t> steps; };

class path_t {
    explicit path_t(std::string_view);               // known-good LITERAL: parses once, aborts if malformed
    static result_t<path_t> parse(std::string_view);  // RUNTIME string; result_t = expected<T, status_t>
    std::span<const std::byte> key() const;          // canonical PATH payload bytes
    const field_path_t& field() const;               // the :field.sub[N] tail
    std::size_t segment_count() const;
};
class path_key_t { /* owned key bytes; ≤16 B inline, else one heap block */ };
struct path_key_hash_t { /* FNV-1a over the key bytes */ };

The two entry points differ in what failure means. path_t::parse is for a string whose validity is itself a runtime condition, and returns status_t::INVALID_PATH (core/src/path.cpp:98-106,119,124). The explicit constructor is for a compile-site literal, where a malformed path is a source bug: it hard-aborts rather than yielding a result_t the caller would only *-deref unchecked (path.hpp:194, defined :335-339). Neither uses exceptions, so both hold under -fno-exceptions.

String → bytes, once

        flowchart LR
    S["/sensor/temp:settings.app.setpoint"] --> P[path_t::parse]
    P --> K["key bytes<br/>06 &quot;sensor&quot; · 04 &quot;temp&quot;"]
    P --> F["field<br/>settings → app → setpoint"]
    K --> M{{"vertex map<br/>(byte-keyed)"}}
    classDef e fill:#dbeafe,stroke:#1e40af
    class M e
    

Consequences

  • No string work reaches dispatch. A path parses at one visible construction site and the graph API takes const path_t&, so a held handle cannot re-parse; every subsequent read, write and subscribe on that path is a byte compare against the map key. The cost of the parse is paid once, at registration or at the literal, not per call.

  • Local and remote addressing are the same bytes. key() returns the PATH-TLV payload that travels on the wire, so a forwarded frame carries the key it was matched on and a remote address needs no translation into a local one.

  • A malformed address fails at the boundary. Empty segments (//), unrooted paths, reserved characters and every limit overrun reject at parse with a typed status_t rather than surfacing as a miss deep in dispatch.

  • The limits are a receiver’s buffer budget. ≤64 B per segment, ≤1024 B total, ≤255 segments (RFC-0023; the byte cap binds first under the current encoding, at 204) and ≤8 field steps (core/include/libtracer/path.hpp:32,34,36,38) let a component size fixed scratch instead of allocating per frame — the mount-prefix walk’s stitch buffer is two segments’ worth, std::array<std::byte, kMaxSegmentBytes * 2> (core/src/fwd_router.cpp), and no longer scales with how wide a mount is (#523).

  • Ordinary names cost no heap block. path_key_t holds records up to 16 bytes inline (path_key_t::kInlineBytes, core/include/libtracer/path.hpp:356) — a packed segment record is a 1-byte length prefix plus the segment text, so a name of up to 15 characters never allocates; longer records spill to a single owned block.

API reference

Generated from core/include/libtracer/path.hpp by Doxygen.

class path_t

A parsed, canonical path: the PATH-TLV payload bytes (packed records) plus the optional field_path_t tail. The payload bytes are the vertex-map key.

Dispatch keys on the parsed bytes (key), never the string form — parse once, hold the value, and every read/write compares bytes (docs/reference/02 §dispatch).

A path also carries the optional bound form (binding, RFC-0024 §7.4). It stays a value type: the binding is an opaque slot the graph and transport tiers fill and validate, never a handle into either of them, so copying a path copies its binding and neither copy can outlive anything.

Public Functions

path_t() = default

An empty path (no segments, no field tail).

inline explicit path_t(std::string_view text)

Construct from a compile-site / known-good path LITERAL, parsing ONCE.

A path_t is FIFTEEN pointer-widths: four owning containers plus a count.

path_t p("/sensor/temp"); write(p, a); write(p, b); — parse the string a single time, then hold the value and reuse the handle; the graph API takes const path_t& so a held path never re-parses on the hot path (docs/reference/02 §dispatch keys on the parsed PATH-TLV bytes, never the string). A malformed literal is a source bug, so this hard-aborts rather than yielding a fallible result_t the caller would only *-deref unchecked. For a RUNTIME string whose validity is a genuine runtime condition, use parse (fallible). explicit — construction is always a visible, deliberate parse, never an implicit per-call one. No exceptions (usable under -fno-exceptions).

Pinned, not observed. A path is a parse-once VALUE the API takes by const&, and each of the two opaque slots (RFC-0024’s binding, RFC-0027’s path label) added a container to it — the exact growth an unwatched type absorbs one RFC at a time until somebody embeds it in a hot struct and pays for it per vertex. Expressed in sizeof(void*) so it holds identically on rv32 and on a 64-bit host, which is the whole point of pinning a shape rather than a number: a change here is a deliberate edit, and it is the moment to ask whether the new member wants to be a slot at all. Same instrument, same reason, as sizeof(path_label_t) == 4.

inline std::span<const std::byte> key() const noexcept

The vertex-map key: the canonical PATH-TLV payload bytes (packed records).

inline const field_path_t &field() const noexcept

The parsed :field tail (empty when the path addresses the vertex value).

inline std::size_t segment_count() const noexcept

The number of segments in the path.

inline const path_binding_t &binding() const noexcept

This path’s bound form, if a mint has completed (RFC-0024 §7.4).

inline bool bind(std::span<const wire::path_ref_element_t> elements)

Record the bound form a mint answered with — elements in ROUTE order.

Element 0 is the origin’s own reference to its first-hop connection vertex; the last is the terminus host’s reference to the target vertex. A mint answers only for the hosts that saw the operation, so the origin stacks its own element under what came back.

Refuses (leaving the path unbound) past the normative element bound: a route with more hosts than a PATH_REF can spell has no bound spelling, and the canonical path this object still holds is the answer — refusing beats truncating, which would produce a valid-looking binding for a different route.

Returns:

true iff the binding was recorded.

inline void clear_binding() noexcept

Drop the bound form and fall back to the canonical one (RFC-0024 §5.3).

What an origin does on a failed validation. There is nothing to tear down anywhere else — no hop holds state for a bound path — so forgetting the elements IS the teardown, and the next operation goes out canonically and may re-mint.

inline const path_label_cache_t &path_label() const noexcept

This path’s PATH-LABEL spelling, if a reply came back minted (RFC-0027 §6.1).

Named for the qualified term throughout, never bare “label”: §11.1 collision 1 was ruled **(a) qualify** at acceptance, so an unqualified “label” still means RFC-0004 §E.1’s per-link u16 and nothing else (route_handle.hpp’s is one), and this RFC’s concept is always the path label.

bool cache_path_label(std::span<const std::byte> body)

Cache the path-label spelling body a minted reply came back with (§6.1).

The NET TIER’s call, never the application’s (§9). The bytes are a packed PATH body: a mixture of literal segments and label elements, in any order, exactly as the reply carried them — mixed paths are legal and expected, because a hop that does not mint simply leaves its own part a string (§5.2).

Refused (leaving the path unlabelled, and nothing else touched) when the spelling has no reading or no point:

  • a body that does not walk cleanly, or that refuses the address on any record — a malformed element is tr::path::invalid, and caching one would hand the next operation an address the far hop must refuse;

  • a body carrying no path-label element — a pure-string spelling is what key already holds, and caching a second copy of it would buy a second thing to invalidate;

  • a body past kMaxPathBytes, the same bound the canonical form is parsed under;

  • a path that is already BOUND (PATH_REF). §11.2 recommends against carrying two compressions of one address — they save the bytes one of them already saved and double the staleness surface for a single route — and this is the one arm where the refusal costs nothing to state, because the slot is new. It is deliberately NOT symmetric: bind is RFC-0024’s shipped surface and keeps its behaviour exactly, since §11.2 is a SHOULD pending §12.4’s measurement and the per-route choice is the mint call site’s (car 4). clear_path_label frees this arm whenever a caller wants the other form.

Returns:

true iff the path-label spelling was cached.

inline void clear_path_label() noexcept

Drop the path-label spelling and fall back to the canonical one (RFC-0027 §7.2).

What an origin does on a NOT_FOUND-class refusal. There is nothing to withdraw anywhere — no unbind frame, no lease, no TTL, no aging (§7.3) — so forgetting the bytes IS the recovery: the next operation goes out in strings, which always work, and the reply after it re-mints.

Public Static Functions

static result_t<path_t> parse(std::string_view text)

Parse and canonicalize a path string (fallible — for a RUNTIME string).

Accepts "/sensor/temp" or "/sensor/temp:settings.deadline_ns". Canonicalizes: strip a trailing /, reject empty segments (//) and unrooted paths, enforce the kMaxSegmentBytes / kMaxPathBytes / kMaxSegments / kMaxFieldDepth limits. A known-good literal uses the parse-once path_t(std::string_view) constructor instead.

Parameters:

text – The path string to parse.

Returns:

The parsed path_t, or a status_t error (e.g. INVALID_PATH).

struct field_path_t

The parsed :field.sub[N] tail of a path — a sequence of field_step_t.

Empty when the path addresses the vertex value itself (no : tail). Drives the field-write / field-read control surface (docs/reference/04).

Public Functions

inline bool empty() const noexcept

True when there is no field tail (addresses the vertex value).

bool operator==(const field_path_t&) const = default

Value equality over the step sequence.

Public Members

std::vector<field_step_t> steps

The .-separated steps; empty ⇒ the vertex value itself.

struct field_step_t

One step of a field path: a NAME and an optional [index] / [] append / [*] wildcard selector.

The parsed form of one .-separated component of a :field.sub[N] tail (docs/reference/03 §addressing). Exactly one of indexed (with index), append, or wildcard is meaningful when a [...] selector is present.

Public Functions

bool operator==(const field_step_t&) const = default

Value equality over every field.

Public Members

std::string name

The step’s NAME (the text before any [...]).

bool indexed = false

True if a [...] selector was present.

bool append = false

True for [] — append to a sequence.

bool wildcard = false

True for [*] — FIELD index_mode=WILDCARD (RFC-0004 §C).

std::uint16_t index = 0

The [N] index; valid when indexed && !append && !wildcard.

The owned key

A path is looked up by an owned copy of its canonical bytes rather than by a string. path_key_t is that copy, with a small-buffer optimization sized so that a packed segment record — a 1-byte length prefix plus the segment text — fits inline for names up to fifteen characters, which is the overwhelming norm; longer records spill to one heap block. It is immutable after construction, matching its use: a vertex’s name never changes. path_key_hash_t and path_key_eq_t are the hash-map bindings over it, and target_key_t is the delivery-target key try_make_target_key builds.

class path_key_t

Owned byte key (a copy of a path’s canonical payload / one segment record).

Small-buffer type (#380 §2): records up to kInlineBytes live inline — a packed record is ONE length byte plus the segment text (RFC-0018), so virtually every vertex name fits and costs NO heap block (a std::vector here allocated ~32 B per named vertex). Longer records spill to one owned heap allocation. Immutable after construction/assignment (matches its use: a vertex’s name never changes, ADR-0057). Move leaves the source empty.

Public Functions

inline explicit path_key_t(std::span<const std::byte> b)

Copy b into the key (inline when it fits, else one heap block).

inline explicit path_key_t(const std::vector<std::byte> &b)

Copy the vector’s bytes (compat shape for path_key_t{vector} callers).

inline path_key_t(const path_key_t &o)

Deep-copy o's bytes (inline or one spill block, as the length needs).

inline path_key_t &operator=(const path_key_t &o)

Replace this key with a deep copy of o's bytes.

inline path_key_t(path_key_t &&o) noexcept

Take over o's bytes (and spill block, if any); o reads empty after.

inline path_key_t &operator=(path_key_t &&o) noexcept

Release this key’s bytes and take over o's; o reads empty after.

inline ~path_key_t()

Free the spill block, if this key owns one.

inline std::span<const std::byte> bytes() const noexcept

The key’s canonical bytes (inline or heap — one uniform window).

inline std::size_t size() const noexcept

The key’s byte length.

inline bool empty() const noexcept

True for the empty key (the root vertex’s name).

inline bool operator==(const path_key_t &o) const noexcept

Value equality over the key bytes.

Public Members

std::byte inline_[kInlineBytes]

In-place record storage (the norm).

std::byte *heap_

The spill block when len_ > kInlineBytes.

Public Static Attributes

static constexpr std::size_t kInlineBytes = 16

Records at or under this many bytes are stored inline (no heap): a packed record is one length byte + the segment text, so names up to 15 characters — the overwhelming norm — never allocate.

struct path_key_hash_t

Hash functor for path_key_t (FNV-1a over the key bytes) — the map hasher.

Heterogeneous (is_transparent): the std::span<const std::byte> overload hashes the SAME bytes to the IDENTICAL value, so a by-span lookup keys the vertex map without materializing an owned path_key_t (the hot internal by-key path in graph_t::find_ptr, which fans out fan_out / bubble_up / ACL-walk / FWD-resolve).

Public Types

using is_transparent = void

Enables heterogeneous (by-span) map lookup.

Public Functions

std::size_t operator()(const path_key_t &k) const noexcept

Hash the key’s canonical PATH bytes.

std::size_t operator()(std::span<const std::byte> k) const noexcept

Hash canonical PATH bytes given as a span — same FNV-1a value as the owned key.

struct path_key_eq_t

Heterogeneous equality for the vertex map (is_transparent): compares path_key_t and raw std::span<const std::byte> key bytes interchangeably, so a by-span lookup needs no owned key. Byte-equality, length included.

Public Types

using is_transparent = void

Enables heterogeneous (by-span) map lookup.

Public Functions

inline bool operator()(const path_key_t &a, const path_key_t &b) const noexcept

True iff the two owned keys hold identical bytes.

inline bool operator()(const path_key_t &a, std::span<const std::byte> b) const noexcept

True iff the owned key’s bytes equal the span’s bytes.

inline bool operator()(std::span<const std::byte> a, const path_key_t &b) const noexcept

True iff the span’s bytes equal the owned key’s bytes.

inline bool operator()(std::span<const std::byte> a, std::span<const std::byte> b) const noexcept

True iff the two spans hold identical bytes.

using tr::graph::target_key_t = std::shared_ptr<const std::vector<std::byte>>

A subscription edge’s canonical PATH key — immutable and refcount-shared.

Shared rather than owned because the dispatch snapshot must outlive a concurrent unsubscribe: vertex_t::snapshot_edges copies each active slot out under an edge pin so the graph can dispatch after releasing it, and the slot may be cleared in between. A deep copy satisfied that and cost a malloc + free per edge per delivery — a std::vector has no small-buffer optimisation, so every non-null key allocated, which is the ordinary local-binding case (/sensor/temp:subscribers[] -> /dev/ctrl0/in/temp). Refcounting satisfies it for an atomic increment instead, exactly as edge_view_t::remote does one field over for the same hazard (#1448 — the whole cold half went the same way, so the snapshot now takes two refcounts and copies no bytes).

Null ⇒ no local re-dispatch target (the callback-only or remote-only edge). The key is built once at admission and never mutated, so sharing it needs no synchronization beyond the control block’s own refcount.

inline target_key_t tr::graph::try_make_target_key(std::vector<std::byte> &&key) noexcept

Wrap key as a shared target_key_t, NOTHROW — null on OOM or empty input.

Mirrors vertex_t::try_make_lkv’s probe-then-commit discipline (#477): under the MCU profile a bad_alloc is an abort(), and admission is reachable from a peer’s bytes (RFC-0014 made registration wire-driven), so this soft-fails by value instead.

Parameters:

key – The canonical PATH key bytes; an empty span yields null (no target).

inline bool tr::graph::valid_segment(std::string_view seg) noexcept

True iff seg is valid as ONE segment of the addressing grammar.

THE segment predicate (ADR-0073 §1): every boundary where a name enters the graph — the local string parser, a wire SPEC creation carrying a child name, a module registration — calls this ONE function, so the tiers cannot drift (#688; the drift, not any single missing check, is the recurring defect — cf. #681). A name that fails here answers INVALID_PATH wherever it is rejected.

Checks: non-empty, at most kMaxSegmentBytes, and none of the SEVEN reserved characters of reference/03 §Reserved characters — / and : are separators, . separates field levels, [ / ] delimit the grammar’s index suffix (which sits OUTSIDE name: segment = name [ index ]), * is the wildcard selector, ? is reserved for the future. Rejecting the brackets is the normative MUST (reference/03 §Reserved characters, incorporated by spec v1 §3); the pre-#996 five-character subset admitted them on the theory that frame[7] travels inside the NAME bytes — ruled the other way: address-index addressing, if it lands, lives outside the NAME bytes (#996, cf. .out-of-scope/range-slice-addressing.md). The character set is pinned cross-tier by the path/path-reserved-brackets conformance vector.

constexpr std::size_t tr::graph::kMaxPathBytes = 1024

Max bytes in a whole canonical PATH payload (docs/reference/03 §limits).

constexpr std::size_t tr::graph::kMaxSegments = 255

Max segments in a path (reference/03 §limits; RFC-0023: min(255, byte cap)).

constexpr std::size_t tr::graph::kMaxSegmentBytes = 64

Max bytes in one path segment (docs/reference/03 §limits; the packed record’s u8 length field caps it at 255 forever, RFC-0018 §5).

constexpr std::size_t tr::graph::kMaxFieldDepth = 8

Max steps in a :field tail (docs/reference/03 §limits).

See: graph, wire-format-bits, reference §addressing.