frame-codec — the TLV wire codec (L2/L3)

In one paragraph

The codec turns wire bytes into a borrowed tlv_t tree and back. A TLV is a 4- or 6-byte header (type, an opt bitfield, a length) + payload + an optional trailer (timestamp, CRC). decode never copies payloads — they are std::spans into the input buffer; encode serializes a tlv_t and recomputes the CRC. The bit-level walkthrough shows every bit.

Decode and encode

decode(bytes) std::expected<tlv_t, err_t> parses exactly one TLV that fills the input: it reads the header, rejects reserved bits and bad structure, verifies the trailer CRC, and — when opt.PL=1 (payload-is-structured) — walks child TLVs iteratively, never recursively. The result borrows the input, so holding it requires keeping the bytes alive (that is what views provide). encode(tlv) does the reverse, recomputing the CRC over the body when opt.CR is set. It does not take the length width from the model verbatim: a body over 0xFFFF widens to the u32 LL form whatever tlv.opt.ll says, because encode emits through emit_tlv (below) — the one home of the length-width policy. A programmatically built tree therefore cannot serialize a length truncated to size & 0xFFFF; bodies at or under 0xFFFF are unchanged and opt.ll is never cleared. Decode failure is one of FRAME_TRUNCATED, FRAME_INVALID, FRAME_CRC_FAIL or TLV_NESTING_TOO_DEEP — the RFC-0002 registry codes, not a decode-only error vocabulary (core/include/libtracer/frame.hpp:26-31).

        flowchart TD
    H["read header:<br/>type · opt · length"] --> V{"bounds ok?<br/>reserved bits zero?"}
    V -->|no| E["err_t"]
    V -->|yes| P{"opt.PL?"}
    P -->|"1 — structured"| C["push children region<br/>onto the open-node stack"]
    P -->|"0 — opaque"| O["payload = span into input"]
    C --> T["verify trailer CRC"]
    O --> T
    T --> N{"more bytes<br/>in region?"}
    N -->|yes| H
    N -->|no| D["tlv_t tree (borrowed)"]
    

The opt byte is the protocol’s compactness lever: six 1-bit flags select structure, trailer contents, and field widths, so the common frame is just 4 bytes of header. Bits 7 and 0 are reserved-MUST-be-zero; a set reserved bit makes the frame invalid (opt_t::kReservedMask, core/include/libtracer/tlv.hpp).

Trailer CRCs

Trailer CRCs are CRC-32C (Castagnoli, reflected poly 0x82F63B78, the default) or CRC-16-CCITT (FALSE) (poly 0x1021, init 0xFFFF, no final xor) when opt.CW=1. Both tables are constexpr and built at compile time — crc32c_table and crc16_table, core/include/libtracer/crc.hpp:38,51 — so a constant-evaluated CRC needs no runtime table build.

Compile-time tables are not the whole runtime story. CRC-32C dispatches once, on first use, to the SSE4.2 _mm_crc32_* or ARMv8 __crc32c* instruction where the CPU carries it, and folds through a portable slice-by-8 table otherwise; all three paths — hardware, slice-by-8, byte-at-a-time — produce byte-identical checksums, which is what lets frozen test vectors hold across targets (crc32c_update_runtime, crc.hpp:168-180). The intrinsics are confined to a target-attributed function so the translation unit stays runnable on a CPU without the extension. The slice-by-8 tables are 8 × 256 × u32 = 8 KiB of rodata that a hardware-CRC CPU never touches (crc32c_slice_tables, crc.hpp:71-83) — the one footprint line a constrained target should know about here.

crc32c and crc16_ccitt each take one span or two, and a crc32c_state / crc16_ccitt_state accumulator takes any number of chunks: a CRC over a payload-plus-trailer region, or over a rope crossing link boundaries, is computed without concatenating the pieces into a fresh buffer. Trailer CRC placement is ADR-0004, CRC in optional trailer.

Frame shape

 byte:  0      1        2   3            4 … (4+len-1)        … trailer …
       ┌──────┬────────┬───────────────┬───────────────────┬─────────────────┐
       │ type │  opt   │ length (u16)  │ payload           │ [timestamp][crc]│
       └──────┴────────┴───────────────┴───────────────────┴─────────────────┘
                  │       (u32 if LL=1)   opaque bytes, OR        TS? then CR?
                  │                       concatenated child
                  │                       TLVs when PL=1
        opt bits (MSB→LSB):  R · PL · TS · CR · LL · CW · TF · R
                             (bits 7 and 0 are reserved-must-be-zero)

Nesting depth

Nesting depth is bounded by the receiver’s decode resources, never by a constant. No depth constant exists in the codec, and an implementation that hardcodes one is not implementing the rule (RFC-0006, resource-bounded nesting depth).

Both decoders share one structural descent, grammar::walk (core/include/libtracer/grammar.hpp, ADR-0048, one wire grammar). Recursion is forbidden there: a malicious deep frame must not overflow a small MCU call stack, so the walk keeps one open-node record per open level in a walk_stack_t. That stack starts in a caller-supplied inline span and, once those slots are used, relocates into geometrically grown blocks drawn from a spill source. The inline span is a tuning knob, not a limit — overflowing it changes cost, not behaviour (grammar.hpp:363-369). Exhausting the spill source rejects the frame with TLV_NESTING_TOO_DEEP, which means exactly “exceeds this receiver’s decode resources” (grammar.hpp:461-465).

The two decoders differ only in what they spill to, and therefore in what bounds them:

decoder

inline slots

spill source

the depth bound is

decode → owning tlv_t

8 (core/src/frame.cpp:126)

the nothrow heap source (frame.cpp:127)

the heap — an owning-tree decode allocates there regardless

decode_intotlv_arena_t

8 (core/src/tlv_arena.cpp:134)

the caller’s mem::block_source_t (tlv_arena.cpp:135)

whatever resource the caller injected

The 8 is the typical FWD nesting (three to four levels) with headroom, not a ceiling: the arena test decodes a frame nested 100 deep (core/tests/tlv_arena_test.cpp:324). A receiver that wants a hard bound gets one by injecting a small source: a stack-buffer mem::bump_source_t makes that buffer the whole decode budget (mem_source.hpp), and exhaustion is then a returned err_t rather than an allocation failure.

Interface

enum class type_t : std::uint8_t { VALUE=0x01, NAME=0x02, /*…*/ STATUS=0x09, ROUTER=0x0D };

struct opt_t {                                   // the 6 option bits
    bool pl, ts, cr, ll, cw, tf;
    static constexpr std::uint8_t kReservedMask = 0b1000'0001;
    static constexpr bool  reserved_set(std::uint8_t);      // bit 7 or 0 set ⇒ invalid
    static constexpr opt_t decode(std::uint8_t);
    constexpr std::uint8_t encode() const;
    constexpr opt_t without_trailer() const;               // clears TS/CR/CW/TF
};

struct tlv_t {
    type_t type;  opt_t opt;
    std::span<const std::byte> payload;                // opaque TLVs (borrowed)
    std::vector<tlv_t> children;                       // structured TLVs (opt.PL=1)
    std::optional<trailer_t> trailer;                  // {timestamp_t?, crc_t?}
};

std::expected<tlv_t, err_t> decode(std::span<const std::byte>);  // borrowed
std::expected<tlv_t, err_t> decode(const view::view_t&);         // the L1→L2 cast
std::vector<std::byte>      encode(const tlv_t&);                // recomputes CRC
std::vector<std::byte>      path_key(const tlv_t& path);         // canonical PATH key
bool                        equal(const tlv_t&, const tlv_t&);   // spans by content

// tlv_emit.hpp — bytes without a model object
void emit_header(std::vector<std::byte>&, type_t, opt_t, std::size_t body_len);
void emit_tlv   (std::vector<std::byte>&, type_t, opt_t, std::span<const std::byte> body);
void emit_name  (std::vector<std::byte>&, std::span<const std::byte>);
void emit_name  (std::vector<std::byte>&, std::string_view);

// tlv_arena.hpp — the terminus decoder
std::expected<tlv_arena_t, err_t> decode_into(std::span<const std::byte>,
                                              mem::block_source_t&);

namespace tr::crc { constexpr std::uint32_t crc32c(...);        // 1 or 2 spans
                    constexpr std::uint16_t crc16_ccitt(...);
                    struct crc32c_state; struct crc16_ccitt_state; }  // n chunks

decode(const view::view_t&) is the L1→L2 cast — “a TLV is a cast from a view.” It lives at L2 because it produces a tlv_t, and consumes an L1 view; the returned tree borrows the view’s bytes, so the view and its segment must outlive it.

Byte emission without a model object

tlv_emit.hpp appends one TLV — <type> <opt> <length> <body> — straight into a std::vector<std::byte>, with no intermediate tlv_t. It is the one representation of the header byte layout (ADR-0048 §3): encode and every structural byte-builder in the tree share it instead of each hand-rolling type/opt/little-endian length. emit_tlv is also the one home of the length-width policyencode goes through it rather than calling emit_header itself, so there is no second widen rule to drift (#924).

function

what it appends

emit_header(out, type, opt, body_len)

the header alone; length is u16 LE, or u32 LE when opt.ll is set. The width follows opt.ll verbatim — this writes a header, it does not decide one

emit_tlv(out, type, opt, body)

header + body, auto-setting opt.ll when body exceeds 0xFFFF. encode routes through here; the forward plane’s fwd_frame_view / stack_writer tiers carry their own copy of the widen rule

emit_name(out, bytes) / emit_name(out, sv)

a NAME TLV with default opt — the metadata-tag workhorse (SETTINGS keys, :schema labels, :children[] members). It is NO LONGER the PATH-segment emitter: RFC-0018 packed a PATH body into [u8 len][utf8] records, which wire::emit_path_segment (packed_path.hpp) writes. The string_view form needs no temporary buffer

emit_path_segment(out, seg)

one packed PATH segment record — [u8 len][utf8], RFC-0018 §5. Returns false, appending nothing, on an empty segment (it would spell the §5.4 escape) or one past 255 bytes

emit_path_element(out, element)

ONE path element, whatever its kind — the byte-exact inverse of path_element_at (path_element.hpp, #1347). A PATH is a list of path elements; an element’s kind is NAME or LABEL; a NAME element is encoded as a segment record, a LABEL element as an escape record — and this switches over the three record emitters so a caller re-spelling a walked body never crosses the two grammars. It lives beside the reader rather than in packed_path.hpp, which is deliberately kind-agnostic. Returns false, appending nothing, for a MALFORMED element and for a LABEL carrying the reserved zero generation

emit_path_ref(out, elements[, type])

a PATH_REF TLV — the 4-byte envelope plus the bare 8-byte element array (RFC-0024 §4). type selects which bound-path code heads it: PATH_REF (0x14, the default) or PATH_REF_REVERSE (0x15, RFC-0024 §7.1 amendment 2), whose body grammar is identical. Returns false, emitting nothing, past the 255-element bound: a route that long has no bound spelling and falls back to the canonical PATH

Building a PATH is emit_path_segment per segment into one buffer (packed_path.hpp); that concatenation of packed [u8 len][utf8] records is exactly the canonical PATH key path_key produces from a decoded PATH, and — since RFC-0018 gave an address exactly one spelling — the body a resolver can key on in place, unconditionally, where a flag on the arena node used to say whether it could. The PATH’s own header carries opt_t{}: a packed body is not a child run, so PL stays clear. Building an FWD request is emit_tlv for the outer frame over a body built the same way. Pass opt_t{.pl = true} for a structured payload.

These live in tr::wire (L2/L3) because they produce wire bytes from wire types; the layer-free little-endian byte helper they build on stays in tr::detail (byteorder.hpp). For decoding, and for emitting a full tlv_t value with payload, children and trailers, frame.hpp’s decode/encode are the entry points.

The BATCH record — folding a flush into one written value

batch.hpp is the one canonical spelling of RFC-0025’s batch convention: BATCH is type_t::BATCH (0x80), the single code assigned inside the user range, and a batch is otherwise an ordinary structured TLV — zero new grammar, so every conforming decoder above already decodes one and the graph never interprets it.

function

what it does

emit_batch(out, base_ns, samples[, offsets_ns])

FOLDS N already-encoded sample frames into one record: a TIME{u64 LE ns} base child, optionally the non-uniform stream’s packed i32 offset run, then the sample frames’ own bytes verbatim. A fold is a concatenation under one header, never a re-encode

batch_wire_bytes(samples_bytes[, offsets])

the whole size function, so a caller sizes the buffer before filling it

read_batch(tlv, dt_ns)

the reader half: a batch_view_t over the base, the offsets and the sample frames

batch_view_t::sample_time_ns(i)

the SAMPLE clock of frame i, derived: base + i × dt_ns on a uniform stream, base + offsets[i] on a non-uniform one

Two properties are worth stating where a reader will look for them.

A uniform stream spends 0 bytes per sample on time. dt_ns is the nominal sample period the stream’s descriptor declares — a SETTINGS LKV beside the data vertex, negotiated once and never repeated per batch — so nothing per-sample is transmitted and a 4-byte sample costs 4 bytes. That is also why dt_ns is a parameter of read_batch and not something the record is asked for: whether the child after the TIME is an offset array or the first sample frame is a fact about the descriptor. A non-uniform stream (dt_ns == 0) carries one packed i32 run in a single child — 4 bytes per sample, contiguous, no per-child TLV header, no anchor walk.

A batch carries no trailer. Wire/TX time is opt.TS on the outermost frame only and is always TF=0; a written value is an inner TLV. Sample time is the payload TIME child; playout time is never transmitted at all (the three-clock model of 01-data-format.md). read_batch declines bytes that do not spell the convention — a reader’s refusal, never the codec’s: the same bytes still decode, still forward and still round-trip.

The terminus arena decoder

Alongside the owning tlv_t model, the codec ships a second decoder for the FWD terminus: wire::decode_into(span, tr::mem::block_source_t&) tlv_arena_t (public header tlv_arena.hpp). It parses the same frames with the same validation — bounds, reserved bits, type 0x00, the bound-path (0x14/0x15) body shape, trailer CRC, trailing bytes ⇒ FRAME_INVALID — but the result is a flat, pre-order array of arena_tlv_t span-nodes: {type, opt, wire (trailer-excluded), body, end}. Every span borrows the input frame; every node is drawn from the injected block_source_t.

The arena contract is structure only, never bytes (ADR-0041, terminus arena decode span contract). The decode holds structure — node types, option bits, subtree extents — and the payload bytes are never copied. A borrowed span may be read, copied once to its owner, or sub-viewed off a refcounted owner; it may never be stored as a borrowed span. The arena is a resolve-scoped view: read it, take the ownership copies, drop it.

Subtree navigation is index arithmetic rather than pointer chasing. end is one past the last descendant, so node i’s children start at i + 1 and siblings walk j = i + 1; while (j < node[i].end) { visit(j); j = node[j].end; }. An opaque node’s end is its own index + 1.

decode_into does not replace decode. The owning tlv_t model remains the general codec — it materializes, it outlives its input by copying, and it is what encode round-trips; the arena form is a resolve-scoped view over an inbound frame, chosen where a decode must not touch the heap. Both are tr::wire, both share grammar::walk, and an equivalence test decodes every conformance vector through each and requires them to agree (core/tests/tlv_arena_test.cpp).

Every draw decode_into makes is nothrow and guarded, because it runs on the wire RX path behind no ACL and a peer chooses both the nesting depth and the node count. Exhaustion answers TLV_NESTING_TOO_DEEP, never std::bad_alloc — which on a -fno-exceptions node is a link-wrapped abort() (core/include/libtracer/tlv_arena.hpp:130-134).

Consequences

  • Self-describing and compact — one 4-byte header covers the common case; the opt bits opt into width and trailer only when needed.

  • Decode is a cast, not a copy — payloads are spans; structured TLVs are sub-spans of the same buffer. Pairs with views for zero-copy.

  • Bounded and safe — fixed-width length (no varint ambiguity), an iterative parse with no recursion and no depth constant, CRC verified before the bytes are trusted.

  • The receiver sets its own ceiling — depth and node count are bounded by the resource the receiver injects, so the same codec serves a heap-backed host and a stack-slab MCU terminus without a build flag.

Pitfalls

  • A decoded tree outliving its buffer. tlv_t::payload and every child payload point into the decode input. Freeing or reusing that buffer while the tree is live is a dangling read; a view_t — and thus its segment — is what keeps the bytes alive.

  • Storing an arena span. arena_tlv_t::wire and ::body point into an inbound frame that the transport recycles. An implementation that stores one instead of copying it once to an owner reads another peer’s later frame.

  • Copying wire without clearing the trailer bits. wire excludes the trailer by construction, so a whole-TLV copy that keeps the source opt byte advertises a trailer it does not carry. opt_t::without_trailer() is the typed fix.

  • Hardcoding a nesting-depth limit. A decoder that rejects at a fixed depth rejects frames a conforming sender may emit, and reports a resource condition it does not have. The limit is the decode resource; the inline slot count is a tuning knob.

  • Ignoring the reserved bits. Bits 7 and 0 of opt must be zero. A decoder that masks them off accepts frames no conforming sender emits and spends the protocol’s extension point.

  • Treating trailing bytes as a second frame. decode and decode_into each require the input to be exactly one TLV; trailing bytes are FRAME_INVALID. Splitting a stream into frames is the transport’s job (length_prefix_framer.hpp).

API reference

Headers: frame.hpp, tlv.hpp, tlv_emit.hpp, tlv_arena.hpp, batch.hpp, path_ref.hpp, crc.hpp — all under core/include/libtracer/.

tr::wire::opt_t — the option bits carried in every header — is documented once, on wire format bits, alongside the bit layout it names.

struct tlv_t

A decoded TLV — the materialized, eager representation of one wire frame node.

For opaque TLVs (opt.pl == 0) payload holds the bytes and children is empty; for structured TLVs (opt.pl == 1) children holds the parsed sub-TLVs and payload is empty. payload (and child payloads) BORROW the input buffer.

Public Members

type_t type = {}

The TLV type code.

opt_t opt = {}

The option bits (pl / cr / ll / …).

std::span<const std::byte> payload = {}

Opaque bytes (borrowed); empty when structured.

std::vector<tlv_t> children = {}

Parsed sub-TLVs; empty when opaque.

std::optional<trailer_t> trailer = {}

The decoded trailer, if present.

struct trailer_t

A decoded TLV trailer: an optional timestamp_t and/or crc_t.

Public Functions

constexpr bool operator==(const trailer_t&) const noexcept = default

Value equality over both optional fields.

Public Members

std::optional<timestamp_t> ts

The trailer timestamp, if present.

std::optional<crc_t> crc

The trailer CRC, if present.

std::expected<tlv_t, err_t> tr::wire::decode(std::span<const std::byte> input, mem::block_source_t &spill = mem::heap_source())

Decode exactly one TLV that fills input.

The structural descent’s open-node stack starts in inline slots sized for the typical FWD nesting and SPILLS to spill for deeper frames, so the RFC-0006 decode-resource bound is a property the caller injects rather than one this function fixes (#873, ADR-0079). decode(input, mem::null_source()) is the spelling of “no spill at all”: a frame nested past the inline slots is refused with TLV_NESTING_TOO_DEEP instead of growing (grammar::walk_stack_t).

Note

spill bounds the WALK STACK only. The returned tree is an OWNING tlv_t, whose child vectors allocate on the global heap by construction — this parameter does not make an owning decode allocation-free, and a caller that needs the whole decode on an injected store wants the terminus arena (wire::decode_into) instead.

Parameters:
  • input – The bytes to decode — must be exactly one TLV; trailing bytes ⇒ FrameInvalid.

  • spill – The block source the walk stack spills into once its inline slots are full. Default: the process heap, i.e. today’s behaviour unchanged.

Returns:

The decoded tlv_t (borrowing input), or an err_t on failure.

inline std::expected<tlv_t, err_t> tr::wire::decode(const view::view_t &v, mem::block_source_t &spill = mem::heap_source())

Decode exactly one TLV from a flat view (the L1↔L2 cast, zero-copy).

The L1↔L2 cast — “a TLV is a cast from a view.” It lives at L2 (it produces a tlv_t) and consumes an L1 view::view_t. The returned tlv_t borrows the view’s bytes, so keep the view — and thus its segment — alive while using it.

Parameters:
  • v – The view whose bytes are exactly one TLV.

  • spill – Forwarded to the span overload — the walk stack’s spill source, defaulting to the process heap.

Returns:

The decoded tlv_t (borrowing v's bytes), or an err_t on failure.

std::vector<std::byte> tr::wire::encode(const tlv_t &tlv)

Encode a TLV to its wire bytes (recomputing the trailer CRC when opt.cr is set).

The length width is not taken from tlv verbatim: a body larger than 0xFFFF widens to the u32 LL form regardless of tlv.opt.ll, the same rule wire::emit_tlv applies (#924), so a programmatically built tree can no longer serialize a length truncated to size & 0xFFFF. A body at or under 0xFFFF is emitted unchanged; tlv.opt.ll is never cleared. A body over 0xFFFFFFFF still truncates modulo 2^32 — the grammar has no wider length form, so that residual is a wire-format limit rather than something encode can express.

Encoding is SYMMETRIC with decode: the grammar’s one per-type structural rule — a PATH_REF body is a fixed-stride 8-byte record array, so opt.PL and opt.LL are both forbidden and the length is bounded (RFC-0024 §4.2/§4.3, wire::path_ref_body_valid) — is applied here too, so this codec cannot mint a frame it would itself reject (#886). A refused TLV anywhere in the tree refuses the whole tree rather than silently dropping a component.

The trailer timestamp is LOUD, not defaulted (#1109): a TLV whose opt.ts is set with no trailer->ts value — or whose trailer value’s relative flag contradicts opt.tf — is refused (empty vector), never emitted with a silently-zero stamp. stamp_ts sets the bit and the value together, so a stamped TLV cannot reach this refusal.

Parameters:

tlv – The TLV tree to serialize.

Returns:

The encoded frame bytes, or an EMPTY vector when tlv (or any descendant) is an ill-formed PATH_REF or claims a timestamp it does not carry. Empty is unambiguous: a serialized TLV always carries at least its 4-byte header, so no well-formed tlv encodes to nothing.

inline void tr::wire::emit_tlv(std::vector<std::byte> &out, type_t type, opt_t opt, std::span<const std::byte> body)

Append one TLV: <type> <opt> <length> <body>.

Length is u16 LE, widening to u32 LE (the LL bit set) when body exceeds 0xFFFF. opt carries the structural bits — pass opt_t{.pl = true} for a structured (list) payload.

The TRAILER bits of opt are cleared by construction (#1109): this emitter writes header

  • body and nothing after, so an opt.ts/opt.cr passed here used to mint a frame that CLAIMED a trailer it did not carry — which a receiver reads as truncation (the grammar counts the trailer into total) or, worse, eats the last body bytes as a bogus stamp. A caller with a trailer to write uses frame.hpp’s encode (values) or emits the header via emit_header and appends emit_trailer_ts / CRC bytes itself (byte builders).

The bound-path element codec (RFC-0024 §4) — the element’s layout and the purely STRUCTURAL rules the grammar enforces. What an element means — the bounds check into a host’s vertex map, the generation compare, the ACL re-check at the dereferenced vertex — is L4 routing and lives in fwd-router.md §the bound hop.

struct path_ref_element_t

One bound-path element: a node-scoped reference to one host’s own vertex.

Element 0 is the origin’s reference to the connection vertex for its first hop; element i is forwarder i’s reference to its connection vertex for hop i+1; the last element is the terminus host’s reference to the target vertex itself. Nothing in an element means anything anywhere but on the host that minted it — it is an address, never a capability (RFC-0024 §4.1, §6).

Public Functions

constexpr bool operator==(const path_ref_element_t&) const noexcept = default

Value equality over both fields.

Public Members

std::uint32_t index = 0

The minting host’s vertex-map index (u32 LE — unreachable on both targets).

std::uint32_t generation = 0

That vertex’s retirement generation at mint time (u32 LE; saturates, never wraps).

constexpr std::size_t tr::wire::path_ref_element_count(std::size_t body_len) noexcept

The element count a PATH_REF body of body_len bytes carries (RFC-0024 §4.3).

There is no count field on the wire: the count IS length / 8. Only meaningful for a body path_ref_body_valid accepts.

constexpr bool tr::wire::path_ref_body_valid(bool pl, bool ll, std::size_t body_len) noexcept

The purely STRUCTURAL PATH_REF rules of RFC-0024 §4.2/§4.3 — the grammar’s check.

A PATH_REF header that fails this is tr::frame::invalid, exactly as a set reserved bit is. All four clauses are shape, not meaning, which is what makes them a codec rule rather than a resolver one (contrast PATH’s child-type constraint, reference/05 §Pitfalls):

  • **opt.PL MUST be 0** — the body is a fixed-stride record array, not child TLVs; a generic PL = 1 walker would read the first four body bytes as a TLV header and mis-frame the whole body.

  • **opt.LL MUST be 0** — the u32 length width buys nothing under a 2040-byte cap, so it is forbidden rather than merely unused.

  • **length % 8 == 0** — a body that is not a whole number of elements has no reading.

  • **length <= 2040** — the §4.3 element-count bound, in bytes.

constexpr path_ref_element_t tr::wire::path_ref_element_at(std::span<const std::byte> body, std::size_t i) noexcept

Read element i out of a PATH_REF body (little-endian, both fields).

Note

Precondition: (i + 1) * 8 <= body.size() — i.e. i is below path_ref_element_count of a body path_ref_body_valid accepted (debug-asserted). The bound is the caller’s to hold because it is free there and not here: a decode path has already had the whole body shape checked by the grammar, so re-deriving the count per element would price a division into every hop of a forward. A caller that cannot prove i — anything walking a foreign frame’s elements — gates its loop on path_ref_element_count first; past the end this reads whatever follows the body, it does not fail.

constexpr void tr::wire::path_ref_store_element(std::span<std::byte> out, const path_ref_element_t &e) noexcept

Write one element into the 8 bytes at out (little-endian, both fields).

Note

Precondition: out.size() >= 8 (debug-asserted). The inverse of path_ref_element_at, and the same contract: the emitter sizes the buffer from the element count before the first store, so the check belongs there once rather than here per element.

inline void tr::wire::emit_name(std::vector<std::byte> &out, std::span<const std::byte> name)

Append a NAME TLV over opaque bytes — the PATH-segment / metadata-tag workhorse.

inline void tr::wire::emit_name(std::vector<std::byte> &out, std::string_view name)

Append a NAME TLV over a text segment (no temporary buffer).

struct arena_tlv_t

One decoded TLV node in a tlv_arena_t (structure only, zero-copy).

Both spans borrow the decode_into input buffer, which must outlive the arena. wire deliberately excludes the trailer so a whole-TLV copy made from it (a stored WRITE value, the reply route bytes) is trailer-less at rest by construction (ADR-0041 §4) — a copier must also clear the trailer bits from the copied opt byte to keep the copy self-consistent.

Public Members

type_t type = {}

The TLV type code.

opt_t opt = {}

The decoded opt bits (trailer bits still set as on the wire).

std::span<const std::byte> wire = {}

Header + body bytes, trailer excluded — the whole-TLV-copy span.

std::span<const std::byte> body = {}

The body: payload bytes (opaque) or the children region (opt.pl).

std::uint32_t end = 0

One past the last descendant’s index (pre-order subtree encoding).

Children of node i start at i + 1; iterate siblings with j = i + 1; while (j < node[i].end) { visit(j); j = node[j].end; }. An opaque node’s end is its own index + 1.

std::expected<tlv_arena_t, err_t> tr::wire::decode_into(std::span<const std::byte> input, mem::block_source_t &src)

Decode exactly one TLV filling input into a flat arena drawn from src.

The terminus-side counterpart of decode (ADR-0041 §1): identical validation (bounds, reserved bits, type 0x00, trailer CRC, trailing bytes ⇒ FRAME_INVALID), iterative (no recursion) with the node array, the sink’s open-node stack and the walk stack all drawn from src — so the caller’s source IS the nesting-depth bound (RFC-0006; no depth constant exists) — but the result is a pre-order arena_tlv_t array of spans into input instead of an owning tlv_t tree — zero heap when src is a stack-buffer mem::bump_source_t. input and src must outlive the arena.

NOTHROW end to end (#588). This function is on the wire RX path and reachable behind no ACL — a peer picks the frame’s nesting depth and node count. Every draw from src is guarded: exhaustion answers TLV_NESTING_TOO_DEEP (“exceeds this receiver’s decode resources”), never std::bad_alloc, which on a -fno-exceptions node is the link-wrapped abort() stub.

class tlv_arena_t

A frame decoded as a flat pre-order node array over borrowed spans.

Produced by decode_into; drawn from the injected memory resource, which must outlive the arena (as must the input buffer). A resolve-scoped object: read it, take the ADR-0041 ownership copies, and drop it — never store it.

Public Functions

inline explicit tlv_arena_t(mem::block_source_t &src)

An empty arena drawing its nodes from src.

inline const arena_tlv_t &root() const noexcept

The root node (index 0). Precondition: a successful decode (never empty).

inline const arena_tlv_t &operator[](std::size_t i) const noexcept

The node at pre-order index i.

inline std::size_t size() const noexcept

Total node count (root + all descendants).

inline std::uint32_t next_sibling(std::uint32_t i) const noexcept

Index of node i's next sibling within its parent (compare against the parent’s end).

inline std::optional<timestamp_t> root_trailer_ts() const noexcept

The ROOT node’s decoded trailer timestamp, if the frame carried one (#1109).

Captured at decode because the node spans deliberately EXCLUDE the trailer (the ADR-0041 §4 trailer-less-at-rest rule), so nothing reachable from a node can read the stamp back — and the reply echo needs exactly this value. Root-only on purpose: the echo reads the request’s OUTER stamp, and per-node capture would grow every arena_tlv_t on the MCU terminus for a value only index 0 is ever asked for. A TF=1 root decodes here too (relative == true), and the CONSUMER decides what an anchorless relative stamp is worth — the echo declines it.

Public Static Functions

static inline std::uint32_t first_child(std::uint32_t i) noexcept

Index of node i's first child (valid iff first_child(i) < (*this)[i].end).

struct crc_t

A decoded trailer CRC: its width and the (zero-extended) checksum value.

Public Types

enum class width_t

The CRC algorithm/width carried in the trailer.

Values:

enumerator CRC32C

32-bit CRC-32C (Castagnoli).

enumerator CRC16_CCITT

16-bit CRC-16/CCITT.

Public Functions

constexpr bool operator==(const crc_t&) const noexcept = default

Value equality over width and value.

Public Members

width_t width = {}

Which CRC algorithm produced value.

std::uint32_t value = {}

The checksum; 16-bit values are zero-extended.

struct timestamp_t

A decoded trailer timestamp: absolute u64 ns or a relative i32 ns delta.

Public Functions

constexpr bool operator==(const timestamp_t&) const noexcept = default

Value equality over the relative flag and value.

Public Members

bool relative = false

false = absolute u64 ns; true = relative i32 ns.

std::int64_t value = 0

The timestamp / delta in nanoseconds.

std::expected<void, err_t> tr::wire::check_frame(const view::rope_t &r)

The cheap INGRESS check (CONTEXT.md §Validation timing): top-level header + total-size anchor + the whole-frame trailer CRC, one linear scan.

Everything ingress is allowed to verify, and nothing more: parse_header on the root (bounds, type == 0x00 reject, reserved-bit reject, LL width, trailer sizing, and — when opt.CR is set — the trailer CRC, a LINEAR link-by-link scan that never needs the tree) plus the total == size anchor. No descent: a malformed child TLV surfaces its error where that level is CONSUMED (per-TLV verify-at-access, ADR-0053), never at ingress. The strict whole-tree walk remains available as the opt-in validate_rope.

Parameters:

r – The reassembled frame. Every link MUST be HOST (view::rope_t::all_host) — a device link cannot be CPU-read to verify a CRC and is rejected with FRAME_INVALID.

Returns:

{} when the root header, size anchor and (if present) root CRC hold; otherwise the err_t the grammar rejects with.

std::expected<void, err_t> tr::wire::validate_rope(const view::rope_t &r, mem::block_source_t &spill = mem::heap_source())

STRICTLY validate one whole TLV frame delivered as a scatter-gather rope (ADR-0048 §1) — the opt-in eager whole-tree walk.

Applies the exact grammar::parse_header grammar — bounds, type == 0x00 reject, reserved-bit reject, LL width, trailer sizing, the two-region CRC, and trailing-bytes reject — over EVERY level of the rope’s links WITHOUT flattening. Iterative (no recursion) via the one grammar::walk; nesting depth is bounded by the walk stack’s spill source (RFC-0006 — no depth constant), which is spill. NOT an ingress step (ingress is check_frame): this is the strict mode a verify-all-then-apply consumer or a differential test opts into (ADR-0053 §4).

The sink models nothing, so the spill block is the ONLY allocation this call can make: validating over an injected spill is allocation-free with respect to the process heap, and mem::null_source() turns it into a fixed 8-level bound that answers TLV_NESTING_TOO_DEEP (#873, ADR-0079).

Parameters:
  • r – The reassembled frame. Every link MUST be HOST (view::rope_t::all_host) — a device link cannot be CPU-read to verify a CRC and is rejected with FRAME_INVALID.

  • spill – The block source the walk stack spills into once its inline slots are full. Default: the process heap, i.e. today’s behaviour unchanged.

Returns:

{} when r is exactly one valid frame; otherwise the err_t the grammar rejects with — identical to decode(flatten(r))’s error for the same bytes, PROVIDED both are given the same depth resources.

The lazy tier — tlv_view_t

A rope-delivered frame does not have to become an owning tree to be read. A tlv_view_t is one TLV whose bytes stay in the rope: it holds the parsed header facts plus a refcounted subrope, and nothing that is not accessed is ever decoded. Children are materialized one header at a time by stepping children_t; a payload handed onward stays the subrope it already is; and materialize() into a tlv_t is the single, explicit copy point.

Validation is lazy in the same sense. Anchoring a view checks the root header and the exact total, with the CRC walk deferred; child headers are grammar-checked as they are stepped over; and integrity — the per-TLV CRC trailer — is checked by whichever consumer accesses a TLV, through verify(). An endpoint whose members form one transaction verifies all of them before mutating any state, so a partially-applied frame is not a reachable outcome.

class tlv_view_t

One TLV whose bytes live in a rope — the lazy decode-side node (ADR-0053 §1).

Holds the (CRC-deferred) parsed header plus a refcounted subrope of the inbound frame; copying a tlv_view_t bumps segment refcounts, never bytes. The view — and any child view or subrope taken from it — keeps exactly its own links’ segments alive (view::rope_t::subrope) and may outlive the transport read loop: this is the owning delivery tier, the scoped revision of the ADR-0041 §2 span-arena contract (which itself is untouched).

tlv_t remains the eager encode-side / materialized representation; materialize is the one explicit copy from this tier into it.

Public Functions

inline type_t type() const noexcept

The TLV type code.

inline opt_t opt() const noexcept

The decoded opt bits.

inline bool structured() const noexcept

True for a structured TLV (opt.PL — body = children region).

inline std::size_t body_size() const noexcept

Body (payload / children region) length in bytes.

inline const view::rope_t &wire() const noexcept

This TLV’s full wire bytes (header + body + trailer) as a rope.

The forwarding primitive: a hop that routed on the PATH prefix hands this (or a body subrope) to the next transport as-is — zero decode, zero copy, the links refcount-alive across the hop (ADR-0053 §3).

inline view::rope_t body() const

The body region as a refcounted subrope (payload of an opaque TLV; the packed children region of a structured one).

Never validated, never copied — a structured payload delivered to a consumer that does not descend stays exactly these bytes (“up to the

consumer to deal with it”, ADR-0053 §1).

inline children_t children() const

Begin lazy child iteration (an empty range for an opaque TLV).

std::expected<void, err_t> verify() const

Check THIS TLV’s integrity — its CRC trailer — now (ADR-0053 §4).

The access-time integrity point: walks this TLV’s body ++ timestamp bytes link-by-link (no copy) against the stored trailer. A TLV with no CRC trailer verifies trivially. Covers nested children byte-for-byte (they are the body), so an endpoint applying a multi-member write as one transaction calls this once — verify-all-then-apply.

std::optional<timestamp_t> timestamp() const

The decoded timestamp trailer, if opt.TS (a bounded stitched read).

std::expected<materialized_t, err_t> materialize(mem::mem_backend_t &backend = mem::heap_backend()) const

The single explicit copy point (ADR-0053 §1): flatten + eager decode.

Everything lazy access deferred is paid here, once, by the consumer that asked for it: one contiguous copy and the full grammar walk INCLUDING every CRC trailer — byte-identical to decode(flatten(wire())).

Parameters:

backend – Where the flat segment is allocated.

Return values:

err_t::FLOW_BACKPRESSUREbackend could not allocate the segment. A LOCAL, transient failure of this node — retrying the same frame may succeed. Until #917 it was reported as FRAME_INVALID, i.e. as a PERMANENT accusation that the peer sent a malformed frame.

Returns:

The flat copy + eager tree, or the grammar’s err_t.

Public Static Functions

static std::expected<tlv_view_t, err_t> over(view::rope_t frame)

The ingress bounds anchor (ADR-0053 §4): adopt frame as one lazy TLV.

Parses the root header with the CRC walk deferred and requires total == frame.total_length() — a handful of byte reads plus an O(links) size sum, no payload walk, no copy. With the root bounds anchored, every later child materialization is containment-checked against its parent’s region, so lazy access is memory-safe without ever touching bytes nobody reads.

Parameters:

frame – The reassembled inbound frame (the rope is adopted — refcounted links, no copy). Every link must be HOST (view::rope_t::all_host): rejected FRAME_INVALID otherwise, exactly as validate_rope.

Returns:

The root view, or the grammar’s err_t (FRAME_TRUNCATED / FRAME_INVALID — including trailing bytes).

class children_t

Lazy forward iteration over a structured TLV’s children (ADR-0053 §1).

Each next parses exactly ONE child header (CRC deferred, so a skipped sibling’s payload is never walked) and yields that child as its own tlv_view_t subrope. A grammar error in a child surfaces here — to whoever is iterating — and only for the child it belongs to; siblings already yielded are unaffected (partial consumption, ADR-0053 §4).

Public Functions

std::expected<std::optional<tlv_view_t>, err_t> next()

The next child, std::nullopt when the region is exhausted, or the child’s grammar err_t.

After an error the iterator is poisoned (further calls return the same error): child boundaries beyond a malformed header are unknowable.

inline bool exhausted() const noexcept

True when the region is cleanly consumed (next would yield std::nullopt; false while poisoned).

struct materialized_t

A materialized view: the flat copy plus the eager tree borrowing it.

root borrows flat’s segment bytes (stable across moves — the segment is refcounted heap memory), so keep the pair together, exactly like decode(view_t)’s “keep the view alive” contract.

Public Members

view::view_t flat

The single contiguous copy of the wire bytes.

tlv_t root

The eager tree; borrows flat’s bytes.

Keys — key_view_t

A vertex-map key is the concatenated packed segment records of its path — each [u8 len][utf8], RFC-0018 — so every ancestor / descendant / child relation is a byte operation and no string form is ever materialized. key_view_t is the navigation over those bytes and the single home of that walking.

Why a byte prefix means “ancestor”: records are self-delimiting and parsed left-to-right, so a shared byte prefix parses identically in both keys and every prefix boundary lands on a record boundary — a differing length byte breaks the byte match one record earlier. /a (01 'a') is a prefix of /a/b (01 'a' 01 'b'); /ab (02 'a' 'b') correctly is not. A strict byte-prefix of a valid key is therefore exactly a strict ancestor of it. This is why an escape record is refused in key context: a key must stay pure-string for the property to be stated over its bytes at all.

class key_view_t

A read-only view over a canonical PATH-payload key (packed [u8 len][bytes] records, RFC-0018), with the ancestor / descendant / segment navigation the graph dispatch and ACL-inheritance walks need. Cheap to copy (wraps a span); borrows the key bytes, which must outlive it.

Public Functions

constexpr key_view_t() noexcept = default

An empty key view (the root).

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

View over the canonical key bytes key (packed segment records).

inline std::size_t record_end(std::size_t at) const noexcept

One past the last byte of the well-framed segment record starting at byte offset at — THE single locus of the [u8 len][bytes] framing decode this whole module is about (#888), and so also where the NEXT record starts.

A bare offset, not a record_t, because this is what the per-record loops of the Composite descent (graph_t::find_ptr) want, and they want it INLINE: returning the fuller record grew the decode past the inliner’s budget and put a call in that loop, which is a vertex-resolution cost this refactor must not introduce. Under the packed body the decode is one byte load and one add — RFC-0018’s whole latency case — where it was a parse_header option decode before.

Returns:

0 when the bytes at at are ragged: no length byte, a length that runs past the key’s end, or a len == 0 record — which in KEY context is the RFC-0018 §5.4 label escape and is rejected here (see the file header; the rule is enforced once and every walk inherits it). A well-framed record ends at at + 2 or later, so 0 is unambiguous. Every walk here stops there — a malformed tail is never half-decoded.

inline std::optional<record_t> record_from(std::size_t at) const noexcept

The whole well-framed NAME record starting at byte offset atrecord_end with the bounds and the payload span worked out.

Return values:

std::nullopt – Exactly when record_end reports ragged.

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

The underlying key bytes.

inline constexpr bool empty() const noexcept

True at the root (no segments).

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

The last segment’s payload — the vertex’s own name; empty at the root. Walks records to the end; stops early on a malformed length (a ragged record, or an illegal zero-length one — see the file header).

inline key_view_t parent() const noexcept

The parent key: this key with its last segment record dropped (empty at the root). The ADR-0020 inheritance walk derives ancestor keys by iterating this — the key is the concatenated segment records, so no string form is needed. Stops early on malformed framing, as last_segment does.

inline bool is_ancestor_of(key_view_t other) const noexcept

True iff this key is a strict ancestor of other — a segment-record-boundary byte-prefix of it (so other is a descendant).

inline std::optional<std::span<const std::byte>> child_record_under(key_view_t parent) const noexcept

If this key is a direct child of parent — exactly one more well-framed segment record beyond it — return that trailing record (the child’s own canonical record encoding); otherwise std::nullopt. A deeper descendant (more than one further record) yields nullopt, as does an illegal zero-length record (see the file header) — the rest.size() <= 1 guard below.

template<class Emit>
inline bool for_each_level(Emit &&emit) const

Invoke emit on each ancestor-prefix level, shallowest-first (the last equals the whole key) — the mkdir -p creation order, ALLOCATING NOTHING.

The storage-free form of split_levels, for a caller on a peer-reachable path where a scratch container would be a heap draw it does not own (#1139).

Parameters:

emit – Called as emit(key_view_t) and returning bool; false STOPS the walk, which this function then reports as failure. A caller that must not act on a malformed key therefore walks TWICE — once with a true-returning emit to validate the framing, then once to act — because raggedness is only discovered at the last record.

Returns:

false if emit stopped the walk, the record framing is ragged (records do not tile the key exactly), any record carries an illegal zero-length payload (see the file header), or the key is empty; true otherwise.

inline bool split_levels(std::vector<key_view_t> &out) const

Append each ancestor-prefix level to out, shallowest-first (the last element equals the whole key) — the mkdir -p creation order.

Returns:

false, appending nothing, if the record framing is ragged (records do not tile the key exactly), any record carries an illegal zero-length payload (see the file header), or the key is empty; true otherwise.

class record_cursor_t

A resumable INDEXED walk over a key’s segment records — at(i) without the rescan-from-zero an indexed accessor would otherwise pay per call.

Non-allocating: its whole state is the borrowed span, two offsets and one record. It exists for the strip-K mount descent (child_registry_t::longest_prefix and fwd_router_t::subscribe_toward), which asks for segment i by index and mostly ascends. An ask BEHIND the walk restarts from the first record — the same answer, at the cost of the rescan a forward ask avoids.

Public Functions

inline explicit record_cursor_t(key_view_t key) noexcept

A cursor over key, positioned before its first record.

inline std::optional<record_t> at(std::size_t i) noexcept

The i-th (0-based) well-framed segment record.

Return values:

std::nullopt – The key has no i-th record — it ended, or a record at or before i is ragged.

inline std::size_t end_of(std::size_t n) noexcept

Where the run of the first n records ENDS: 0 for n == 0, the key’s size when fewer than n records are well-framed.

The offset a strip-K descent slices its residual at — key.subspan(end_of(k)) is everything below the mount, and end_of(k) >= size

is “the address named the

mount exactly, with nothing below it”.

struct record_t

One well-framed segment record of a key: where it sits, and what it carries.

begin is the offset of its 1-byte length prefix, end one past its last payload byte — so end is also where the NEXT record starts, and the whole record’s encoding is bytes().subspan(begin, end - begin).

Public Members

std::size_t begin = 0

Offset of the record’s u8 length byte.

std::size_t end = 0

One past the record’s last payload byte.

std::span<const std::byte> payload

The segment’s payload bytes.

The grammar core

The header/trailer rules — the type-0x00 reject, the reserved-bit reject, the LL length width, trailer sizing, the two-span CRC — are parsed and validated in one place, read through a small chunk cursor so the same rules serve every byte source. Both materializing decoders funnel through it. The cursor is the byte-source seam: span_cursor is the contiguous case and rope_cursor walks links, stitching a straddled header into a bounded scratch.

Cursor window containment — what holds in a RELEASE build (#986)

The cursors’ bounds contracts are not uniformly debug-only, and the split is deliberate rather than incidental. It rests on one asymmetry: a span_cursor’s window is its whole object, so overshooting it forms an out-of-range subspan — undefined behaviour that the fuzz and sanitizer CI reports. A rope_cursor’s window is a soft bound inside a longer link chain, so an overshooting read walks bytes the chain genuinely holds. Nothing faults, no sanitizer can see it, and the caller is handed real bytes from the wrong place and told it succeeded.

reader

violation in a release build

rope_cursor::for_each_span

truncated to the window and the cursor latches (poisoned()); parse_header answers FRAME_TRUNCATED, the forward-plane gather drops the frame

span_cursor::for_each_span

UB — caught by fuzz/ASan CI, the backstop the rope case lacks

rope_cursor::byte_at / load_le

debug-asserted only; callers bounds-check per read, and a wrong point read is one byte where a wrong feed is an unbounded run

region (both)

debug-asserted only

The guarantee is spent on the bulk reader alone because it was priced. Giving the contiguous cursor the same clamp-and-latch measured compact-forward at x0.66 deliveries/s and compact-terminus at x0.82 — reproduced in 4/4 and 3/4 interleaved pairs with disjoint ranges — since a min()-derived subspan length costs the CRC feed loop what a directly-derived one gives it, and carrying a latch byte takes the cursor past two registers. A latency regression on the delivery path is an automatic reject here, so span_cursor::poisoned() is a static constexpr false that folds the shared grammar’s check away entirely.

What the shipped shape costs, measured: zero on the pinned symbols (bench/symbol_ratchet.json, including route_fwd_forward<rope_cursor>), zero on the Cortex-M0 P0 flash footprint — a span-only MCU never instantiates the rope cursor at all — and x1.00 on the interleaved delivery-path A/B. Targets that link a rope-delivering transport pay +50 B in parse_header<rope_cursor> (+3.0%) on rv32.

rope_cursor_assert_test gates the debug half; rope_cursor_release_guard_test gates this one, compiled with NDEBUG forced on so it cannot pass vacuously.

struct header_t

A validated TLV header + trailer: the sink-neutral parse result.

Offsets/sizes only (no payload span), so each sink extracts the spans it wants — the owning tree its payload/trailer, the arena its trailer-excluded wire + body — from the TLV’s own bytes. All offsets are relative to the TLV’s start.

Public Members

type_t type = {}

The TLV type code (never 0x00).

opt_t opt = {}

The decoded opt bits.

std::size_t header = 0

Header length: 4, or 6 with the LL bit.

std::size_t length = 0

Body (payload / children region) length.

std::size_t ts_size = 0

Timestamp-trailer bytes (0 / 4 / 8).

std::size_t crc_size = 0

CRC-trailer bytes (0 / 2 / 4).

std::size_t total = 0

Full encoded size: header + body + trailer.

enum class tr::wire::grammar::crc_check_t : std::uint8_t

When parse_header checks a CRC trailer (ADR-0053 §4).

VERIFY is the eager decoders’ policy (and the default — every pre-existing caller is unchanged): the trailer is checked during the header parse, which walks the whole payload. DEFER is the lazy tier’s policy: sizing and bounds are validated but the payload is never touched, so iterating past a sibling costs O(header) — integrity is checked by whichever consumer accesses the TLV (tlv_view_t::verify), per the end-to-end argument.

Values:

enumerator VERIFY

Check the CRC trailer now (walks the payload).

enumerator DEFER

Skip the CRC walk; integrity is the accessor’s to check.

template<class Cursor>
std::expected<header_t, err_t> tr::wire::grammar::parse_header(const Cursor &cur, crc_check_t crc_policy = crc_check_t::VERIFY)

Parse + validate ONE TLV header and its trailer at offset 0 of cur.

Applies the whole grammar — minimum size, type == 0x00 reject, reserved-bit reject, LL length width, the PATH_REF fixed-stride body shape (RFC-0024 §4.2/§4.3), trailer sizing, and the two-span CRC (payload ++ timestamp, fed without concatenation) — but does not recurse into a structured payload’s children; the sink’s iterative walk does that. On success the trailer has already been CRC-verified; the caller only re-reads the stored timestamp/CRC bytes it wants to model.

Template Parameters:

Cursor – A byte-source cursor (span_cursor, or the rope cursor).

Parameters:
  • cur – The cursor positioned at the TLV’s first byte.

  • crc_policy – CRC-trailer policy (crc_check_t). Defaults to VERIFY (the eager decoders’ behavior); the lazy tier passes DEFER so skipping a sibling never walks its payload (ADR-0053 §4).

Returns:

The validated header_t, or the err_t the grammar rejects with (FRAME_TRUNCATED / FRAME_INVALID / FRAME_CRC_FAIL).

template<class Cursor, class Sink>
std::expected<void, err_t> tr::wire::grammar::walk(const Cursor &root, Sink &sink, walk_stack_t<Cursor> &stack, crc_check_t crc_policy = crc_check_t::VERIFY)

Drive the iterative TLV descent, modelling each node through sink (ADR-0048 §1 — the ONE structural walk).

The recursion-free open-node stack machine that turns validated headers into a tree, shared by both materializing decoders: the owning tlv_t tree (frame.cpp decode) and the terminus arena (tlv_arena.cpp decode_into). ADR-0048 §1 unified the header grammar (parse_header); this unifies the descent that was still hand-written twice, held equal only by the decode↔decode_into equivalence test. Only the SINK differs — grafting owning children vs appending pre-order arena nodes.

Recursion is forbidden (a malicious deep frame must not overflow a small MCU call stack, docs/reference/01 §Iterative parsing requirement): the walk keeps its open-node state in stack, whose inline-slots + spill shape IS the receiver’s nesting-depth bound (RFC-0006 — no depth constant exists). A frame that exhausts the stack is rejected with TLV_NESTING_TOO_DEEP, amended to mean “exceeds this receiver’s decode resources”.

The sink models each node through three hooks (balanced LIFO on success; a rejected frame abandons the sink mid-walk):

  • on_open(const header_t&, const Cursor& node) — a structured TLV opens;

  • on_leaf(const header_t&, const Cursor& node) — an opaque TLV;

  • on_close() — the current open node’s children are complete. node is a cursor over that TLV’s OWN bytes (offset 0 = its first byte), from which the sink extracts the spans it wants (payload / wire / trailer) using the header’s offsets.

Template Parameters:
  • Cursor – A byte-source cursor (span_cursor, or the rope cursor).

  • Sink – A type providing the three hooks above.

Parameters:
  • root – The cursor positioned at the frame’s first byte.

  • sink – The node model (built as the walk visits).

  • stack – The open-node stack (must be fresh/empty) — the caller’s decode-resource bound (walk_stack_t).

  • crc_policy – CRC-trailer policy forwarded to parse_header.

Returns:

Nothing on success, or the first err_t the grammar rejects with (including FRAME_INVALID for trailing bytes after the root, and TLV_NESTING_TOO_DEEP on stack exhaustion).

struct span_cursor

The contiguous byte-source cursor — the grammar’s only source today.

A thin adaptor over one std::span: the grammar reads its bytes through this seam (parse_header) so the identical rules serve a rope cursor (link-walking, ADR-0048 §1) once that lands, with no rule change here.

Public Functions

inline std::size_t size() const noexcept

Number of bytes available from the TLV’s start.

inline span_cursor region(std::size_t off, std::size_t len) const noexcept

A sub-cursor over the [off, off + len) window of this cursor.

The contiguous analogue of rope_cursor::region — a plain subspan, so the same cursor-generic code (a forward-plane header read, a child descent) narrows either source with one call.

inline std::uint8_t byte_at(std::size_t off) const noexcept

The unsigned byte at offset off.

inline std::uint64_t load_le(std::size_t off, std::size_t n) const noexcept

Load n little-endian bytes at off as a u64 (byteorder.hpp).

template<class Fn>
inline void for_each_span(std::size_t off, std::size_t n, Fn &&fn) const

Visit the n bytes at off as contiguous sub-spans, in order.

The CRC-feed seam (parse_header): a contiguous source yields exactly one span, so this is a straight call; the rope cursor yields one span per straddled link, letting the identical feed cross a link boundary with no concatenation buffer.

Note

Precondition: off + n <= size(), enforced in every build (#986) — the feed is truncated to the window and poisoned latches.

Public Members

std::span<const std::byte> buf

The bytes this cursor reads over.

Public Static Functions

static inline constexpr bool poisoned() noexcept

Never latched — a contiguous cursor cannot serve a byte from outside its window, so it has nothing to report (#986).

The rope source’s latch exists because its window is a SOFT bound: the link chain physically continues past it, so an overshooting feed reads real bytes belonging to another part of the rope and nothing faults. A span_cursor’s window is its whole object — for_each_span clamps to it, and past that there are no bytes to serve, wrong or otherwise.

static constexpr, not a member: this makes if (cur.poisoned()) in the shared grammar fold away entirely on the span source, and — the reason it is written this way — keeps the cursor exactly one std::span wide. Carrying a bool here cost compact-forward −26% deliv/s and compact-terminus −14% (measured, 4/4 and 3/4 interleaved pairs, disjoint ranges): at 24 bytes the cursor stops being two registers, and walk/region pass one per descent.

class rope_cursor

The rope byte-source cursor — the link-walking twin of span_cursor.

Reads the grammar’s bytes across an ordered chain of view::view_t links so the same parse_header rules serve a scatter-gather frame. A window of size bytes ANCHORED at a (link index, intra-link offset) origin; region narrows it to descend into a structured node’s children region — the rope analogue of the span cursor’s subspan.

Public Functions

rope_cursor() noexcept = default

An empty cursor (zero bytes) — the walk-stack inline-slot default.

inline explicit rope_cursor(const view::rope_t &r) noexcept

A cursor over the whole of rope r.

inline std::size_t size() const noexcept

Number of bytes available from this cursor’s origin.

inline bool poisoned() const noexcept

Has any read through THIS cursor been truncated at the window edge? (#986)

The release-mode half of for_each_span’s containment contract. A bulk feed that overshoots the window is clamped rather than served, and latches this — so the decode boundary can answer FRAME_TRUNCATED (parse_header does) instead of consuming a short feed as if it were whole. Sticky: it is never cleared, because a frame that produced one wrong-length read is not made sound by a later good one.

Note

Checked on the cursor that was FED. region hands out a copy, so a sub-cursor’s latch does not travel back to its parent; the boundaries that map this to an error (parse_header, the forward-plane gather) each test the cursor they passed, which is why a temporary sub-cursor — the shape walk and peek_fwd_* descend with — is still covered.

Note

Point reads (byte_at, load_le) keep the debug-only contract: their callers bounds-check per read against size, and a wrong point read is one byte where a wrong feed is an unbounded run. Closing them for release is a separate signature question (they return values, not void).

inline std::pair<std::size_t, std::size_t> anchor() const noexcept

This cursor’s origin as a (link index, intra-link offset) anchor.

inline rope_cursor region(std::size_t off, std::size_t len) const noexcept

A sub-cursor over the [off, off + len) window of this cursor.

O(links crossed by off) — walks the anchor forward from THIS cursor’s origin (never from link 0) and re-windows, sharing the same link chain and copying no link. Used to descend into a node’s children region exactly as decode_into subspans the payload.

Note

Precondition: off + len <= size() (debug-asserted) — the same containment contract span_cursor::region gets for free from std::span::subspan, and that view::view_t::subview asserts.

inline std::uint8_t byte_at(std::size_t off) const noexcept

The unsigned byte at offset off (walks to its link).

Note

Precondition: off < size() (debug-asserted). The grammar callers bounds-check before every read; this makes that contract visible and a violation loud instead of a silent wrong byte.

inline std::uint64_t load_le(std::size_t off, std::size_t n) const noexcept

Load n little-endian bytes at off as a u64 (stitched across links).

ONE seek then a forward walk — not n seeks. The trailer loads (parse_header’s length field, a CRC value, an 8-byte timestamp) are the cursor’s densest reads, and paying the chain walk per byte made a straddling timestamp cost eight of them.

template<class Fn>
inline void for_each_span(std::size_t off, std::size_t n, Fn &&fn) const

Visit the n bytes at off as contiguous per-link sub-spans, in order.

The CRC-feed seam: a range wholly inside one link yields a single span; a straddling range yields one span per link it crosses, so the grammar’s incremental CRC crosses a link boundary with no concatenation buffer.

Note

Precondition: off + n <= size(). Violating it is DEFINED, in every build (#986): the feed is truncated to the window, so no byte from outside it is ever handed to fn, and poisoned latches — see that accessor for why the window is enforced here rather than left to the caller. The debug assert below still fires first, so a violation stays loud in a debug build and CI.

Public Static Functions

static inline rope_cursor at(std::span<const view::view_t> links, std::size_t li, std::size_t intra, std::size_t len) noexcept

A cursor over len bytes of links starting at link li, byte intra — the RESUMABLE form.

Lets a caller that already walked to a position (the lazy child iterator, tlv_view.hpp) re-enter the chain there instead of from link 0, which is the same Θ(children × links) the anchored origin removes inside grammar::walk.

Note

Precondition: the anchor names a byte the chain holds (or its exact end, for an empty window) and len bytes follow it.

template<class Cursor>
struct walk_frame_t

One open structured node’s traversal state in walk: a cursor over its children region and the walk position within it.

Template Parameters:

Cursor – A byte-source cursor (span_cursor, or the rope cursor).

Public Members

Cursor body = {}

Cursor over the open node’s children region.

std::size_t pos = 0

Walk position within body.

template<class Cursor>
class walk_stack_t

The walk’s open-node stack — the RFC-0006 receiver-resource depth bound.

Nesting depth is bounded by the receiver’s decode resources, never by a constant. The stack starts in the caller-provided inline_slots span — a TUNING knob, not a limit: overflowing it changes cost, not behavior — and, once those are exhausted, relocates into geometrically grown blocks drawn from spill (one open-node record per open level, RFC-0006).

A null spill makes the inline span the receiver’s whole decode budget: exhaustion makes push return false, which walk maps to TLV_NESTING_TOO_DEEP (“exceeds this receiver’s decode resources”) — a clean reject, no throw, safe under -fno-exceptions. A non-null spill is drawn from NOTHROW (#588): exhaustion there returns nullptr and takes the SAME reject path, so the depth bound is honest whether or not a spill exists.

Note

spill is a tr::mem::block_source_t and deliberately not a std::pmr::memory_resource. It used to be the latter, and grow used its throwing allocate unguarded — so a peer sending a frame nested past the inline slots could reach __cxa_throw’s abort() stub on a -fno-exceptions node, from the RX decode path, behind no ACL (#588). The reject this replaces it with was already specified; only the allocation was dishonest.

Public Functions

inline walk_stack_t(std::span<walk_frame_t<Cursor>> inline_slots, mem::block_source_t *spill) noexcept

A stack over inline_slots, spilling to spill when they run out.

inline ~walk_stack_t()

Releases the spill block, if any (the inline slots are the caller’s).

walk_stack_t(const walk_stack_t&) = delete

Non-copyable (one walk, one stack — the spill block has one owner).

walk_stack_t &operator=(const walk_stack_t&) = delete

Non-assignable.

inline bool push(const walk_frame_t<Cursor> &f)

Open one node. False ⇔ the receiver’s decode resources are exhausted — the inline slots are full and the spill is absent OR itself exhausted. Never throws (#588).

inline bool empty() const noexcept

True when no node is open.

inline walk_frame_t<Cursor> &back() noexcept

The innermost open node.

inline void pop() noexcept

Close the innermost open node.

Stream framing

Splitting a byte stream back into frames is the transport’s job, and it is one state machine rather than one per transport. length_prefix_framer reassembles u32-LE length-prefixed frames from arbitrary chunks: each complete frame lands in one exactly-sized refcounted segment drawn from the caller’s backend, so there is no library-owned buffer and exactly one copy off the wire. An allocation failure is backpressure — the frame is drained so framing sync survives, and counted — while an oversize prefix is malformed, because a desynchronized stream cannot be re-framed and the caller must tear the connection down. The state machine names no transport type, which is why it is tested directly with no live connection.

class length_prefix_framer

Reassembles u32-LE length-prefixed frames from an arbitrarily-chunked stream.

Fed one chunk at a time via feed; each completed frame is delivered through the caller’s on_frame callback as a fresh, exactly-sized segment. Holds only partial-reassembly state (a prefix scratch + the in-flight segment), so one framer serves one stream and is reused across a stream’s chunks; reset discards partial state when a new peer’s stream takes over.

Public Functions

template<class OnFrame, class OnDrop>
inline result_t feed(mem::mem_backend_t &backend, std::size_t max_frame, const std::byte *p, std::size_t n, OnFrame &&on_frame, OnDrop &&on_drop)

Feed n bytes at p; deliver each completed frame via on_frame and report each backpressure drop via on_drop, both in arrival order.

The ORDERING is the contract, not an implementation detail (#1255): on_drop runs at the moment the drop is decided, so it always precedes the on_frame of any frame that arrives after the dropped one — including when both share a single chunk, which is the common case for small frames. A caller whose on_drop bumps a counter therefore gives every receiver this guarantee: by the time a delivered frame is observable, every drop before it is already counted. A relaxed atomic is enough to carry it — the increment is sequenced-before the delivery, so whatever release/acquire edge publishes the frame to an observer (a sink’s mutex, a queue) carries the count with it.

Template Parameters:
  • OnFrame – Callable void(tr::view::segment_ptr_t seg, std::size_t len)seg owns exactly len bytes of one reassembled frame.

  • OnDrop – Callable void() — one backpressure drop was just decided.

Parameters:
  • backend – Where each frame’s segment is allocated (ADR-0042 §2/§4).

  • max_frame – The caller’s PROTOCOL frame ceiling (configured_cap): a prefix beyond it is malformed and stops the feed. A frame within it that the backend cannot hold — exhausted, or larger than any segment it produces (effective_cap) — is drained and reported through on_drop instead, so local capacity backpressures a legitimate peer rather than disconnecting it (#932).

  • p, n – The chunk (may split a prefix or a body arbitrarily).

  • on_frame – Invoked once per completed frame, in arrival order.

  • on_drop – Invoked once per frame shed to backpressure, at decision time.

Returns:

Per-chunk result_t: whether an oversize prefix stopped the feed (the caller shuts the peer down).

inline void reset() noexcept

Discard partial reassembly state (a new peer’s stream reuses the framer).

Public Static Functions

static inline constexpr std::size_t configured_cap(std::size_t max_frame) noexcept

Resolve a :settings max_frame value into the per-connection cap — TIGHTEN-ONLY against kDefaultMaxFrame.

0 (unset) keeps the default; a nonzero value yields min(max_frame, kDefaultMaxFrame). The setting arrives through a config-writable key (a connection SPEC’s :settings), so it may only narrow what the node will buffer off the wire — never widen it above the protocol default (#1035). Every framed transport (tcp / ws / quic / webtransport) assigns its per-connection cap through this one home.

static inline std::size_t effective_cap(const mem::mem_backend_t &backend, std::size_t max_frame) noexcept

The effective RX frame cap: min(max_frame, backend.max_segment_size()) — the largest frame this connection can actually DELIVER.

The bound is the injected resources’ real capacity, not a magic number (the no-synthetic-limits doctrine). It is a deliverability bound, not the protocol’s framing bound: a length above it but within max_frame is shed as backpressure, not treated as malformed (#932) — see on_prefix. Transports that refuse an oversize frame from a DECLARED length before buffering a body byte (the WS frame header) compare against this.

static inline prefix_decision_t on_prefix(mem::mem_backend_t &backend, std::size_t max_frame, std::size_t len)

Apply the shared framing rules to one decoded u32 length prefix.

Parameters:
  • backend – Where an accepted frame’s segment is allocated (ADR-0042 §2/§4).

  • max_frame – The PROTOCOL cap (from configured_cap) — the only bound whose violation means the stream is desynced. A length within it that the backend cannot satisfy (exhausted, or simply larger than any segment this backend produces) comes back as DROP, so a legitimate peer is backpressured rather than disconnected over OUR local capacity (#932).

  • len – The decoded length prefix.

Returns:

The prefix_decision_t; ACCEPT carries the freshly allocated segment.

Public Static Attributes

static constexpr std::size_t kPrefixBytes = 4

The u32-LE length prefix’s size on the wire (transport framing).

static constexpr std::size_t kDefaultMaxFrame = 16u * 1024u * 1024u

The default receive frame cap when :settings max_frame is unset (16 MiB) — one home for the default every u32-length-prefixed stream transport (tcp / quic / webtransport) restates as its kMaxFrame.

A prefix announcing more is malformed (corrupt or hostile): the stream has lost framing sync and the connection is torn down. A per-connection :settings max_frame may TIGHTEN the cap below this default, never raise it; the effective cap is further bounded by the injected backend’s real capacity (effective_cap — the no-synthetic-limits doctrine).

struct prefix_decision_t

What one length prefix means under the shared framing rules.

The single home for the per-prefix decisions every u32-length-prefixed stream applies identically: len == 0 is a no-op record, len beyond the protocol cap is malformed (a desynced stream cannot be re-framed), and a frame the backend cannot hold — alloc failed, including because the frame is larger than any segment this backend can ever produce — is backpressure (drain the body, count a drop). feed applies them to a chunk-fed stream; a pull-mode blocking reader (tcp_transport_t) applies the same rules but reads the body straight off the socket into the accepted segment — adopting feed there would add a scratch-buffer copy on the hot path (ADR-0042 §2/§4), so the rules are shared instead of the state machine.

Public Types

enum class kind_t : std::uint8_t

The decision kind.

Values:

enumerator EMPTY

len == 0 — the record carries no TLV; skip it.

enumerator MALFORMED

len exceeds the PROTOCOL cap — tear the stream down.

enumerator DROP

The backend could not hold the frame (alloc failed, or the frame exceeds this backend’s segment size) — drain the body, count a drop.

enumerator ACCEPT

seg owns exactly len bytes, ready to fill.

Public Members

kind_t kind = kind_t::EMPTY

The decision.

tr::view::segment_ptr_t seg

The accepted frame’s segment (ACCEPT only).

struct result_t

The outcome of feeding one chunk (the transport applies the effects).

Deliveries happen inline through on_frame and drops inline through on_drop; this reports only the one outcome that STOPS the feed, which the caller must act on with its connection handle. It deliberately carries no drop COUNT: a per-chunk tally can only be read after the whole chunk has been processed, i.e. after deliveries the drop preceded (#1255).

Public Members

bool malformed = false

A prefix beyond the PROTOCOL cap was seen — stop, shut the peer down.

See the reference data-format for the normative rules and wire-format-bits for worked byte dumps.