config — the build’s named traits type

In one paragraph

Every compile-time knob a libtracer build has is a member of one named type, tr::graph::default_config_t — sizes, padding widths, and the two policies the build selects (which ACL evaluator, which last-known-value slot). An application picks its configuration by making tr::graph::config_t name its own traits type, once, app-wide; every loose spelling in the library is derived from that alias. A second reader, tr::wire::config_reader_t, is the runtime counterpart: typed accessors over a config SETTINGS TLV that arrived from a peer.

What it does

Two different things are called “configuration” here, and keeping them apart is the point.

Build configuration is default_config_t. It is not a set of macros and not a template parameter: it is a struct whose members are static constexpr values and nested type aliases, bound once by an alias. Being one named entity is what makes a configuration diffable, passable to a test, and assertable on — a scatter of independent #defines can only be read one at a time. It is bound per build rather than threaded through the API because threading it through produces byte-identical machine code while forking the process-global stripe and hazard tables, which costs exactly the RAM the configuration exists to save.

The knob-by-knob narrative — what each knob costs on which target, and which ones are optimization rather than correctness — is the configuration space. This page is the API surface.

Runtime configuration is config_reader_t. A connection’s settings arrive as a SPEC config SETTINGS TLV: positional NAME-key / value pairs, string values as NAME children and integers as VALUE children. All six transport-side consumers used to hand-roll the same walk — the universal keys plus the tcp, ws, can, quic and webtransport factories — and this class is their one home. Unknown keys are ignored so a newer peer can send more than a receiver understands, a key whose value child has the wrong type is ignored, and a repeated key resolves to its last well-formed occurrence. Each factory still reads only its own keys — what is shared is the walk, not the vocabulary.

The walk is pair-consuming: it advances a whole (key, value) pair at a time, so an unknown key is skipped together with its value. That is what lets the tolerance coexist with positional pairing. Scanning every offset instead made the grammar ambiguous — a pair whose string value textually equalled a known key (link_hint = "addr") had that value re-read as a key, binding the following child as addr and, under last-wins, destroying a legitimate earlier one. A child that is not a NAME where a key belongs desynchronizes the stream and the walk stops there rather than guessing a resync.

Which keys those factories actually read — the universal set and each kind’s private one, with the wire value each takes — is connection config. This page is the walk; that page is the vocabulary.

config_reader_t is the one home for the pair walk itself, everywhere it is read tolerantly. It lives in tr::wire (#985) — the layer that owns the grammar — and the transport plane names it there too, so graph_t::create_child (the creation SPEC) and the SUBSCRIBER QoS SETTINGS parse at L4 read through the same type rather than carrying hand-written copies of the rule. The one deliberate exception is graph::parse_acl (#906) — the same mechanics under the opposite unknown-key ruling, which is to reject: an ACL is a security document, and the two duplicate-key families (#995) must not share code that could drift one toward the other.

Declaring your own build configuration

Inherit, override what differs, and bind the alias in a header your build puts ahead of libtracer/config.hpp on the include path:

struct my_node_config_t : tr::graph::default_config_t {
    static constexpr std::size_t kCacheLineBytes = 0;   // single-core: no false sharing
    using guard_t = my_rtos_critical_section_t;         // interrupt-masked, never spins
    using lkv_slot_t = tr::graph::single_writer_slot_t;
};
using config_t = my_node_config_t;

Inheriting rather than copying means a knob added later inherits its new default instead of failing to compile.

The two policies

A policy is a type the configuration selects, not a runtime flag, so the branch it removes is not on the hot path at all.

ACL policy — acl_policy_t picks how an access decision is evaluated. allow_only_policy_t is the MCU profile: ALLOW entries only, no ordering question to answer. full_acl_policy_t is the host profile: ordered first-match-per-bit, DENY included. See security & ACL for what the entries mean.

LKV slot policy — lkv_slot_t picks how a vertex publishes and reads its last-known value. single_writer_slot_t is the default: a plain shared_ptr swapped and copied inside guard_t (an interrupt-masked critical section on a chip, the address-striped mutex_guard_t on a host), with no registry and a publish that cannot fail. (kSingleWriter, the one-publisher-per-vertex promise an RTOS build used to state, was removed in #1718: no code read it.) hazard_slot_t is the lock-free host opt-in: an atomic<node_t*> reclaimed with hazard pointers, which costs a fixed registry sized by kHazardReaderSlots, a heap node per published value, and a publish that can fail under memory exhaustion. Every policy returns an owning handle from load() and declares may_spin, and a build whose kSpinWaitSafe is false refuses a policy that can spin. The refcount slot sp_atomic_slot_t was removed because libstdc++ spin-locks it (#1618).

Pitfalls

  • Bind the alias, do not edit the struct. Overriding config_t is a supported target-configuration change; editing default_config_t in place puts your build on a fork of the library.

  • kCacheLineBytes = 0 is a legal value. It means “this target has no second core to false-share with”, and it is an optimization knob in both directions — never a correctness one.

  • A config_reader_t borrows. The reader and every std::string_view it returns point into the bytes its tlv_node_t borrows; both die with those bytes.

  • Ignoring an unknown key is deliberate. A reader that rejects settings it does not recognize breaks forward compatibility with a newer peer. This is the opposite ruling from parse_acl, which rejects an unknown key: an ACL is a security document, where silently dropping an attribute widens access. The tolerance is safe here only because the skip takes the whole pair.

API reference

struct default_config_t

The target’s build configuration, as ONE named type (ADR-0070).

Every compile-time knob is a member here, and the loose names below are DERIVED from it. That ordering is the point: the configuration is a single diffable entity an application can name, pass to a test, and assert on — rather than a scatter of independent declarations that can only be read one at a time.

It is bound once per build, not threaded as a template parameter. ADR-0070 records why, with measurements: threading it produces byte-identical machine code (verified across eight knob combinations, five optimization levels and two targets), so it buys no latency; its one unique capability — two configurations in one binary — would FORK the process-global stripe and hazard tables, costing exactly the RAM the configuration exists to save; and an app-declared traits type cannot reach the library’s out-of-line translation units anyway, so it would layer on this header rather than replace it.

Declaring your own. Write a libtracer/config_override.hpp and put its directory ahead of core/include on the include path. This header includes it — after this struct, so the defaults are already visible to inherit from — and uses whatever config_t it binds:

// libtracer/config_override.hpp
#pragma once
namespace tr::graph {
struct my_node_config_t : default_config_t {
    static constexpr std::size_t kCacheLineBytes = 0;  // single-core: no false sharing
    using guard_t = my_rtos_critical_section_t;
};
using config_t = my_node_config_t;
}  // namespace tr::graph

Inheriting from default_config_t means a knob added later does not break your preset — it inherits the new default instead of failing to compile. Stating only the differences is also what keeps the override honest: there is no second copy of the defaults to rot.

Public Types

using acl_policy_t = allow_only_policy_t

The target’s selected ACL policy (ADR-0047 §1 build-time module set).

Default: the ALLOW-only MCU profile. A host binds the full security_acl policy (ordered first-match-per-bit with DENY) in its override fragment — a target-configuration change, never an edit to graph.cpp. Override fragment: using acl_policy_t = full_acl_policy_t;. (The -DLIBTRACER_ACL_FULL CMake option that used to write that line was removed in #1722; configuring with it is an error.)

using guard_t = ::tr::mutex_guard_t

The target’s ONE critical-section type (RFC 0028 §5.5): the guard single_writer_slot_t opens around its pointer swap and its handle copy, AND the default Sync policy of tr::mem::synchronized_pool_t.

One trait, not two: before slice 10 the pool had its own pool_sync_policy vocabulary (spin_sync_t / portmux_sync_t), bound separately from this one. It must model tr::guard (guard.hpp): lock() / unlock(), a static for_address(const void*) the slot takes, and the is_isr_safe / is_nonblocking / may_spin / name traits. It also serializes the write-sequence bump on a core with no atomic read-modify-write (tr::rmw_counter_t).

Spelled reader_guard_t until #1703. A fragment that still defines that name is refused at compile time (see the tripwire after config_t), because a fragment whose binding the library no longer reads would fall back to mutex_guard_t without a word.

mutex_guard_t by default: a table of address-striped one-word locks — one RMW to take, a release store to give back — whose contender re-reads briefly and then sleeps instead of spinning on a descheduled holder; unrelated vertices rarely share one. A single-core RTOS build binds an interrupt-masked critical section (the ESP-IDF component: tr::esp::critical_guard_t). The guard must never spin-wait where kSpinWaitSafe is false — that is the whole of #1618 — and a guard that declares may_spin is refused there by both the slot and the pool.

using lkv_slot_t = single_writer_slot_t

The target’s selected LKV slot policy (ADR-0069 §1).

How a vertex publishes and reads its last-known value. Default: single_writer_slot_t, one value_t* swapped and retained inside guard_t. It has no registry, no deferred reclamation and a publish that cannot fail, and its one wait is the guard (#1618).

hazard_slot_t is the lock-free alternative for a host whose reads of one shared vertex contend across many cores. Override fragment: using lkv_slot_t = hazard_slot_t;. The named type must satisfy the contract in lkv_slot.hpp — in particular load() returns an OWNING handle, and the policy declares may_spin, which is refused where kSpinWaitSafe is false.

using reclaim_policy_t = reclaim_local_t

The target’s selected RECLAMATION policy (ADR-0080) — WHEN the library may free the memory behind a retired subscription’s {fn, ctx} pair.

Default: reclaim_local_t

, whose grace point is “this thread’s dispatch stack unwinds

to depth 0”. It is the default because it makes the MCU and the host build behave IDENTICALLY:

reclaim_strict_t forbids unsubscribing from inside a delivery, which would make the same application code legal on a host and illegal on the constrained target — a portability bug that surfaces only where it is hardest to debug.

What the default costs, and what rebinding buys, is one non-atomic increment, decrement and branch per fan_out on a per-thread counter — once per publish, regardless of subscriber count, and nothing at all on a publish nobody subscribed to (the counter sits after fan_out’s no-subscriber gate). Override fragment: using reclaim_policy_t = reclaim_strict_t; — worth taking only where the deployment can show that re-entrant unsubscribe does not occur, because reclaim_strict_t cannot see it outside a debug build.

reclaim_qsbr — ADR-0080’s third policy, whose grace point spans EVERY thread — is the one to bind when this node dispatches from several threads at once and unsubscribes from another, the case neither policy above covers. Override fragment: using reclaim_policy_t = reclaim_qsbr_t;. It prices one relaxed load and one seq_cst store per OUTERMOST fan-out (not per edge), and it is the only policy whose release hook may run on a thread other than the unsubscribe() caller — see reclaim.hpp.

Public Static Attributes

static constexpr std::size_t kVertexLockStripes = 16

The number of lock stripes shared by every vertex in the process (#361 §2).

Override fragment: static constexpr std::size_t kVertexLockStripes = 8;; ESP-IDF: menuconfig CONFIG_LIBTRACER_VERTEX_LOCK_STRIPES, which writes exactly that line. A small single-core node reclaims RAM at 4-8.

What N costs, precisely: N * sizeof(vertex_stripe_t) bytes of .bss reserved at LINK time (plus the same for the condvar table) — the table is not lazy, whatever the platform does. What IS lazy is the platform primitive behind each handle: on FreeRTOS a stripe’s mutex costs ~90 B of heap on its first lock, so an untouched stripe costs its struct and no heap. Most of the struct is padding, and kCacheLineBytes decides how much.

static constexpr std::size_t kCacheLineBytes = 64

The target’s cache-line size for false-sharing padding — or 0 where false sharing cannot happen, because the target has no second core to share with.

libtracer pads the shared tables whose slots unrelated threads hit concurrently (the vertex lock stripes; the hazard domain’s cells and retire lists) up to this boundary, so two threads working on two slots never fight over one line. On a single-core node that padding buys nothing and the bytes are pure loss: measured on rv32 (-Os, real core/src/graph.cpp, GCC 15.2), 16 stripes cost 1,024 B of at 64 and 128 B at 0 — 896 B of a single-core node’s static RAM spent against a hazard it does not have.

This is an OPTIMIZATION knob, never a correctness one: 0 on a multi-core target costs throughput under concurrent control-plane verbs and changes no observable behaviour. Override fragment: static constexpr std::size_t kCacheLineBytes = 0;. The ESP-IDF component derives it from CONFIG_FREERTOS_UNICORE — a unicore build has no second core by construction, so the right value is not a question the integrator should be asked.

Values below a padded type’s natural alignment are raised to it, not applied ([dcl.align]/5 makes a reduction ill-formed, and GCC ignores it silently); each padded type static_asserts that the alignment it asked for is the one it got.

static constexpr std::size_t kHazardReaderSlots = 64

How many threads may hold a hazard announcement at once (ADR-0069 §3).

Read this as “threads that concurrently touch a vertex’s LKV” — readers announce, writers park displaced nodes, and both claim one index for the life of the thread. A per-target knob rather than a thread ceiling picked out of the air (RFC-0006); override fragment: static constexpr std::size_t kHazardReaderSlots = 24;.

Sizing: one index per such thread, and nothing at all unless lkv_slot_t is bound to hazard_slot_t — the default binding (single_writer_slot_t) never references the registry, so it is never emitted. Undersizing is not a correctness problem: threads past the bound share one reserved index under a spin lock, so they serialize with each other and with nobody else.

What the domain costs when it IS bound, measured on rv32 at N = 64 (-Os, real core/src/graph.cpp, GCC 15.2) — the padding knob dominates it:

kCacheLineBytes

registry .bss

TU .bss + .sbss

64

8,384 B

11,649 B

0

1,828 B

4,197 B

Note the third term the registry figure does not cover: binding the slot also pulls in roughly 2 KB of libstdc++ __waiter_pool_base .bss (the atomic::wait back-end), which is why the TU column is not just the registry plus the stripes.

static constexpr std::size_t kInlineFanout = 8

Subscribers a publish snapshots into its stack buffer before it falls back to the overflow vector (#1708) — a TUNING knob, never a limit.

A publish copies the vertex’s live edges into a fixed buffer of edge_view_t on its own stack, so a fan-out up to this width reaches no allocator. A wider fan-out snapshots into the publishing thread’s reusable overflow vector instead (zero-alloc once warm) and delivers to every subscriber exactly as before: the width selects a strategy, it refuses nothing and reports nothing.

The buffer is reserved on EVERY publish frame whether the vertex has one subscriber or eight, at kInlineFanout * sizeof(edge_view_t) bytes: 384 B on a 64-bit host (48 B a view) and 224 B on rv32 (28 B). The host default keeps the allocation-free fast path for the widths the benches gate; a NARROW node whose vertices carry one or two subscribers spends that stack on every task that publishes. Measured on rv32 (-Os, GCC 15.2, real core/src/graph.cpp, -fstack-usage): graph_t::fan_out’s frame is 304 B at 8 and 128 B at 2 — the 168 B of six views plus 8 B of 16-byte frame rounding. Override fragment: static constexpr std::size_t kInlineFanout = 2; — the ESP-IDF component sets 2 on a chip target. At least 1.

static constexpr std::size_t kEdgePinSlots = 32

How many threads may hold an EDGE PIN at once (#635) — the per-participant announcement the fan-out snapshot claims while it copies a vertex’s published edge array out.

Read this as “threads that may publish (`graph_t::write`) concurrently”. Each claims one index for the life of the thread; the pin itself is held only across the copy-out, never across a dispatch. A per-target knob rather than a thread ceiling picked out of the air (RFC-0006); override fragment: static constexpr std::size_t kEdgePinSlots = 24;.

Correctness never depends on this number — only scaling does. A thread that finds every index taken falls back to copying the CURRENT array under the vertex stripe mutex, which is safe for the reason the mutex existed in the first place: displacing an array requires that same lock, so the current array cannot be retired underneath the fallback reader. Undersizing costs those threads exactly what every thread paid before #635.

Unlike kHazardReaderSlots this registry is ALWAYS emitted — the publish path is not a policy binding. Its .bss is N * max(kCacheLineBytes, alignof(void*)) bytes: at N = 32 that is 2,048 B on a host (64-byte padding) and 256 B on a single-core MCU profile, which sets kCacheLineBytes to 0 and has no second core to false-share against.

static constexpr std::size_t kMaxVertexBytes64 = 88

The RAM-diet RATCHET on sizeof(vertex_t), 64-bit targets (#361 §8).

Pinned to the size actually measured, with NO headroom, so the next byte added is a build failure. That is the point: the goal is a lean vertex, and a ceiling held above the measured size cannot express it. A ceiling only answers “did you regress past a

fixed point”; both 112 B and 96 B satisfied the old 120 B ceiling, so the 16 B the diet won after #380 §1 were invisible to the build and free to be spent again by anyone. Pinned to the measurement, every byte reclaimed is kept by construction.

History, newest first: 88 unchanged by #1621 (the 32-bit write sequence frees 4 B that the member order’s tail padding absorbs on a 64-bit host), 88 (RFC-0028 slice 3 — the LKV slot is one value_t* word, where the std::shared_ptr<const rope_t> it replaced was two; both lkv_slot_t bindings agree), 96 (measured across all three CI legs — acl_full OFF/ON and both lkv_slot_t bindings agree), 112 post-#380 §1, 144 post-packing, 168 post-§3, 160 post-§2, 248 post-§1, 536 pre-split.

It lives HERE, in the configuration, because it is a per-target budget — and it is enforced in vertex.hpp beside the type it constrains, so every build on every target evaluates it under its own binding. It used to sit in a test, which meant it gated exactly one configuration and never the 32-bit one at all (no CI leg compiled that test cross-target, while the ESP-IDF legs compiled vertex_t itself on every PR).

Two rules follow from pinning it. RAISING one is a reviewed decision, never a way to make a build pass. LOWERING one is the routine half: a change that shrinks vertex_t lowers the number in the same commit, or the gain is handed back to the next author.

static constexpr std::size_t kMaxVertexBytes32 = 64

The RAM-diet RATCHET on sizeof(vertex_t), 32-bit (MCU) targets.

Pointer-halved, and pinned to the measurement on the same terms as kMaxVertexBytes64 — measured 64 B on rv32 (-Os -fno-exceptions -fno-rtti, rv32imac_zicsr_zifencei/ilp32).

Lowered 72 -> 64 by #1621 (RFC-0028 D6): write_seq_ became a 32-bit atomic. The 64-bit one was 8 B wide AND 8-aligned on rv32, so it cost 4 extra bytes plus 4 of alignment padding, and a libatomic call (__atomic_fetch_add_8, which masks interrupts on ESP-IDF) on every publish.

This number was 80 with a note claiming rv32 was “exactly 80” and had zero headroom. It was 72 by then: the struct had shrunk and the prose had not, which is exactly the drift a pinned number cannot have — the assert is re-derived from a measurement, while a sentence describing the size is only as true as the day it was written.

Earlier: raised 72 -> 80 with the #380 §2 name-key SBO, whose inline buffer adds <= 8 struct bytes on 32-bit but deletes a ~32 B heap block per named vertex — and on this target the heap is what is actually scarce.

static constexpr std::size_t kShareThresholdBytes = 4096

The copy-or-share threshold (RFC-0028 §5.3, D3): a written value of AT LEAST this many bytes is SHARED, one below it is COPIED into the value’s own block.

The default every vertex answers until its vertex_policy_t::share_threshold_bytes gives it its own. At ingress, “shared” means the stored value links the inbound receive segment (refcount, zero copy) and “copied” means the bytes land inline in the one value_t block the publish costs (value_t::make_inline: one allocation, one memcpy). A payload whose TLV carries a CRC/TS trailer is always copied — a shared frame’s opt byte cannot be patched — and so is one whose reader cannot share (a borrowed, span-delivered frame).

0 shares always; SIZE_MAX copies always. The two ends are the old kPinNever and “pin unconditionally”, now edge cases of one comparison on the absolute payload size — the variable that was actually measured (RFC-0028 R6); the retired ratio was the measurement rig’s rotation knob (#774).

Sharing BORROWS the receive segment, and this is the knob that prices

it

A shared value holds its whole inbound RX segment for as long as it is the vertex’s last-known value (or sits in a STREAM ring). On a pooled RX backend that is a pool slot — receive capacity the transport cannot use until the value is displaced. The library makes the deferred release safe; only the application knows its pool geometry and retention pattern, so the budget is the consumer’s number (RFC-0022 Amendment 2). Size against live shared values x segment_bytes; bench/README.md

§”RFC-0022 §6 —

receive-pool occupancy” measured a 29-slot ESP32-C6 pool collapsing once the live shared set crossed the slot count.

Target-class defaults (§11 ruling 2):

  • host (WIDE / MID) — 4,096 B, RFC-0022 Amendment 2’s knee: below it the copy is cheaper than the borrow, above it the zero-copy share wins.

  • NARROW — a per-build trait: a fixed, small RX pool states its own value in its config_override.hpp (the ESP-IDF component binds SIZE_MAX, copy always, which is exactly the posture it shipped before this knob).

static constexpr auto kSizeClasses = size_class_ladder_t<16, 8, 65536>::kTable

The size-class table of the host slab pool (ADR-0083 Decisions 4 and 5, #1775, #1777): the block sizes a class serves, ascending.

The host default root (mem_slab_pool.hpp, tr::mem::host_root_t) carves every block it serves from a slab of one class: a request takes the smallest class that holds it, and the host allocator sees only whole slabs. A size-class boundary is therefore a row in this one table, not a property of the platform allocator. That is what removes the 1 KiB cliff #1768 measured: a 1024 B value’s 1072 B one-block segment is a 1152 B block here, from the same per-thread cache a 1000 B one comes from, where glibc served it on a slower path than a 1032 B request.

Default: 16 B to 64 KiB, 8 classes per doubling. Eight classes of 16 B up to 128 B, then eight evenly spaced classes in every doubling above it (144 … 256, 288 … 512, … 61440, 65536): 80 rows, so no block wastes more than one eighth of its size. A request above the last row, or one aligned past 64 B, is drawn from the root as whole slabs of its own (kSlabBytes multiples) and returned there when freed.

Every row must be a multiple of 16 (alignof(std::max_align_t) on the reference hosts), so every block keeps the platform allocator’s alignment, and the table must be non-empty and strictly ascending, with at most 255 rows; the pool asserts all three. A node whose payloads cluster (mostly 4 KiB frames) states its own table, and pays no slab for a class it never draws: a class takes its first slab on its first request.

Read only where kSlabPool is true. The ESP-IDF component binds that false, so the table is not read on that target.

Override fragment: static constexpr std::size_t kSizeClasses[] = {64, 256, 1024, 4096};.

static constexpr bool kSlabPool = true

Whether this build’s default root is the host slab pool (ADR-0083 Decision 4, #1777).

true (the host default): a graph_t built without a source derives its value, table and net sub-pools from tr::mem::host_root(), and tr::mem::heap_backend(), value_source(), table_source(), net_source() and net_backend() all draw from them. The host allocator then serves whole slabs only (kSlabBytes), never a request per value.

false: those defaults are the platform heap directly, one request per block, which is what every build did before #1777. A target with no small-block fast path and a few hundred KiB of RAM gains nothing from 64 KiB slabs, so the ESP-IDF component binds false; its static-arena default is ADR-0083 Decision 12’s step 6. No sub-pool is derived there, and the :stats.mem.values, .tables and .net seams answer SCHEMA_NOT_FOUND.

Override fragment: static constexpr bool kSlabPool = false;.

static constexpr std::size_t kSlabBytes = 65536

The slab the host slab pool draws for a class, in bytes: the smallest unit it asks the root for (#1777). A power of two.

A class draws a slab of this size, or the next power of two that holds 8 of its blocks if that is larger, aligned to its own size, so a block finds its slab by masking its address. A slab is carved lazily, one block at a time, so its untouched pages cost the node address space and not resident memory. Default 64 KiB: one slab holds 8 or more blocks of every class up to 7 KiB.

Override fragment: static constexpr std::size_t kSlabBytes = 16384;.

static constexpr std::size_t kSlabClassCap = 2

The fully free slabs a host slab-pool class keeps (ADR-0083 Decision 6, #1777).

A slab whose last block comes back is RELEASED to the root when its class already keeps this many fully free slabs, and kept for the next burst otherwise, however many slabs of the class are live, so a long-running service gives back what a burst took instead of holding its peak forever. Nothing is released on a timer (there is none in the library): tr::mem::host_root_t::trim() releases every fully free slab on the application’s own schedule. At least 1, so a class that alternates one allocation and one free does not draw and release a slab each time.

Override fragment: static constexpr std::size_t kSlabClassCap = 1;.

static constexpr std::size_t kGuardStripes = 64

Locks in the host guard’s process-wide stripe table (#1716); a power of two.

Applies when the build keeps the default guard_t, and then the library then binds tr::basic_mutex_guard_t<kCacheLineBytes, kGuardStripes>, so the table costs kGuardStripes * max(kCacheLineBytes, 1) bytes of .bss — 4 KB at the defaults. Two vertices share a lock only when their addresses hash to one stripe, and a shared stripe costs a contended section, never correctness. Override fragment: static constexpr std::size_t kGuardStripes = 16;. Ignored when guard_t is bound to anything else (an interrupt-masked guard has no table).

static constexpr bool kForceGuardedRmw = false

Force the guarded binding of every tr::graph::bound_rmw_counter_t the library owns — the write sequence and the router’s labelled-hop count — even where the counter’s width is natively lock-free (#1715, #1697).

A test knob: it lets a host build exercise the path a target without a native atomic RMW takes, where the write-sequence bump runs under guard_t and is fused into the LKV publish section. Production code leaves it false; the native binding is then selected exactly as before and its generated code is unchanged.

static constexpr std::size_t kDeferredReleaseSlots = 16

How many retired {ctx, release} pairs ONE THREAD may hold parked at once, when reclaim_policy_t defers (ADR-0080).

Read this as “subscriptions unsubscribed from INSIDE a single delivery stack”. The ordinary unsubscribe — from outside any callback — parks nothing at all, so on most nodes this storage is touched zero times; it exists for the re-entrant case reclaim_local_t supports and reclaim_strict_t forbids.

Sizing: one retired_callback_t per slot, in per-thread storage that is plain bytes with no destructor and no allocation — 256 B on a 64-bit host at the default, 128 B on a 32-bit MCU, and only on a thread that actually dispatches. Nothing at all under reclaim_strict_t, which never defers.

Overflow is a REFUSAL, not a failure: a pair that finds every slot taken is DROPPED and its hook is never run — a leak, deliberately, because the alternative is running a release hook while the fan-out that is still walking the snapshot names that context. graph_t::deferred_release_drops() counts every one, so an undersized bound is observable rather than silent. Override fragment: static constexpr std::size_t kDeferredReleaseSlots = 64;

Under read it differently.

There the pairs are held across a cross-thread GRACE PERIOD rather than a dispatch stack, in ONE shared table rather than per-thread storage, so the quantity it bounds is “retirements in flight process-wide

while some participant has yet to quiesce” — a larger and less predictable number. Raise it (64 is a sane starting point) on any node that unsubscribes in bulk while other threads dispatch. The overflow rule is identical, and so is its observability: a QSBR build retries the scan once after publishing the pair, so a drop means a participant thread genuinely never reached a quiescent state — an embedder defect

tr::graph::graph_t::deferred_release_drops now names.

static constexpr std::size_t kQsbrParticipants = kEdgePinSlots

How many threads may participate in the reclaim_qsbr_t grace period at once (#1376) — the per-thread quiescent-state announcement a retiring thread scans.

Read this as “threads that may dispatch (`fan_out`, or an ADR-0049 durability latch)

concurrently”. Derived from

kEdgePinSlots rather than picked out of the air, the way lkv_slot.hpp’s kRetireBatch is derived from kHazardReaderSlots — the two sets are the same threads, since a thread that publishes is a thread that dispatches.

**Unlike kEdgePinSlots, correctness is not indifferent to this number** — but it fails SAFE, not silently. A thread that finds every index taken cannot announce itself, and a thread a scan cannot see is one no grace period may conclude past; so it counts itself into an overflow tally instead, and any non-zero reading blocks all reclamation until it clears. The failure mode is therefore deferred frees (visible as tr::graph::graph_t::deferred_release_drops rising), never a use-after-free.

Its .bss is N * max(kCacheLineBytes, alignof(std::uint64_t)) bytes — at N = 32 that is 2,048 B on a host — plus the shared retired table. It is emitted only in a build that actually binds : graph.cpp reaches the domain exclusively from if constexpr branches a non-QSBR build discards, and GCC emits nothing at all for those — 0 symbols and 0 B of .bss, verified. Override fragment: static constexpr std::size_t kQsbrParticipants = 64;

static constexpr std::size_t kDeviceBackendSlots = 2

How many DEVICE-space memory backends may register a transfer hook at once (#1381) — the bound on tr::mem::register_device_backend’s table.

An L0 (tr::mem) fact, here for the same reason kSpinWaitSafe is: ADR-0070’s rule is that the configuration is ONE named type. tr::mem::kDeviceBackendSlots is its spelling for the memory layer.

Read it as “vendor accelerator backends this process binds” — a GPU tier module, an NPU one, a dmabuf one. Two is the honest default: a host that talks to one accelerator family needs one slot, and nothing in-tree registers at all.

Its .bss is N * (sizeof(void*) + sizeof(void(*)())) — 32 B on a 64-bit host at the default — and it is emitted only in a build that links device_backend.cpp. A single-backend (LIBTRACER_BACKEND_SET_POOL_ONLY) target has no device arm and never links that TU, so the cost there is 0 B rather than “small”. Override fragment: static constexpr std::size_t kDeviceBackendSlots = 4;

Overflow is a REFUSAL, not a failure: register_device_backend returns false and registers nothing, so a backend that could not claim a slot moves no bytes at all (tr::mem::transfer answers false for its segments) rather than silently sharing another vendor’s hook.

static constexpr bool kSpinWaitSafe = true

Whether a task on this target may SPIN-WAIT for a lock another task holds (#1158).

An L0 (tr::mem) fact, but a member HERE because ADR-0070’s rule is that the configuration is ONE named type: a knob that lives outside it cannot be set by an override fragment, which is exactly the defect that kept this one in the build system. tr::mem::kSpinWaitSafe is its spelling for the memory layer, derived like every other loose name below.

True on a multi-core host: the holder runs on a different core, so a spinner makes progress possible and the O(1) section costs less than a mutex round-trip. FALSE on a priority-preemptive scheduler, where a spinner that outranks the holder never yields the CPU the holder needs to release the lock — the wait becomes unbounded priority inversion and the board hangs in the watchdog rather than merely running slowly. That is true of a single-core chip and equally of an SMP chip whose spinner and holder share a core.

Asserted against the pool’s sync policy (mem_pool.hpp) and against every LKV slot policy’s may_spin (vertex.hpp, #1618).

static constexpr bool kBusLinks = false

Whether this target carries the ADR-0044 BUS facet at all — peer-named links, per-peer addressing, in-band peer enumeration (#375 deliverable 3).

A tr::net fact, and a member HERE for the reason kSpinWaitSafe and kDeviceBackendSlots are: ADR-0070’s rule is that the configuration is ONE named type, so a knob that lives outside it cannot be reached by an override fragment. tr::net::kBusLinks is its spelling for the transport plane.

What it closes. A bus link reaches MANY peers and names each of them, so the routing plane carries a second addressing tier for it: the registry stamps a mount’s bus SHAPE and resolves a residual segment as a peer (child_registry_t::resolve_peer, by_name’s peer fallback), fwd_router_t::add_child wires the peer-named receiver and both peer-lifecycle notifiers, and a connection vertex synthesizes its :children[] from the link’s live peer table. Bound false, every one of those consumers folds to the point-to-point answer at COMPILE time through tr::net::bus_of (transport.hpp), and the peer-named machinery behind them is never reached — ADR-0047 §1 link-time module selection, expressed as a configuration member rather than as a TU list, because whether a tcp/ws listener is peer-named is a WIRING-time choice inside a TU that a bus-less target still compiles for its point-to-point half.

What it costs to carry and what the default saves. Measured on rv32 (-Os -fno-exceptions -fno-rtti, rv32imac_zicsr_zifencei/ilp32, GCC 15.2, per-TU .text), closing it removes 1,400 B of flash from fwd_router.cpp and 678 B from transport_vertex.cpp — 2,078 B — and 0 B of .bss, because the tier is code and per-instance state, not a static table. A LIBTRACER_NET_PLANE=OFF build gains nothing: it never compiled those TUs in the first place.

Default — the lean choice (#1670, v0.17.0). A node whose links are point-to-point — one dial upstream, or a listener that serves its peers as one broadcast link (ADR-0001’s originating firmware shape) — pays nothing for a tier it never uses.

Who opts in. A node that runs a peer-named listener (peer_named=true tcp/ws), the ESP-IDF WS server httpd_ws_link_t, or ANY CAN link — the last two are buses by construction. Override fragment: static constexpr bool kBusLinks = true; — the core test build, the bench/ build and the ESP-IDF CONFIG_LIBTRACER_BUS_LINKS (which CONFIG_LIBTRACER_WS_SERVER selects) do exactly that.

It is a REFUSAL, never a silent downgrade. A build that binds it false and then asks for a bus is rejected, loudly and at the earliest door that can speak: compiling LIBTRACER_TRANSPORT_CAN (a bus by construction) is a static_assert, and a peer_named=true tcp/ws listener is refused by its SPEC factory and reports transport_t::ok() == false when constructed directly. Quietly serving such a configuration as FLAT would be worse than either: the listener’s own per-frame tier select would keep delivering peer-named into a sink the router never installed.

static constexpr bool kSelfHealLinks = false

Whether this target carries the RFC-0014 §4 S5 LINK-LIVENESS ENGINE at all — self_heal_link_t, its worker thread and its backoff/redial state machine (#1470).

A tr::net fact, and a member HERE for the reason kBusLinks is: ADR-0070’s rule is that the configuration is ONE named type, so a knob that lives outside it cannot be reached by an override fragment. tr::net::kSelfHealLinks is its spelling for the transport plane.

What it closes. The engine is minted on exactly one path — a config-kind DIAL whose kind was registered with transport_kind_traits_t::self_heal_dial (transport_vertex.cpp, create_connection_locked). Bound false, that mint is discarded at COMPILE time, nothing references self_heal_link_t, and the TU is dropped from the archive by the build lists that also gate it (LIBTRACER_SELF_HEAL_LINKS in core/CMakeLists.txt, CONFIG_LIBTRACER_SELF_HEAL_LINKS in the ESP-IDF component).

What it costs to carry and what the default saves. Measured by the reporter on an ESP32-C6 image (riscv32, -Os -fno-exceptions -fno-rtti, same sdkconfig) across the release that made the TU unconditional: nm on the linked ELF finds 4,336 B of reachable self_heal_link_t symbols (worker_main, attempt_locked, reap_locked, …) out of a +6,224 B image bump, and 0 B of .dram0.bss — the engine is code and per-instance state, not a static table.

Default — the lean choice (#1670, v0.17.0; re-rules #1548’s default). The built-in udp/tcp/ws DIAL kinds then dial EAGERLY, exactly as they did before #1548 made them engine-managed: the connection comes up at creation, with no redial, no backoff and no liveness publishing beyond UP.

Who opts in. A node that wants its config-created DIAL connections minted DORMANT and self-healing, or that registers its own self_heal_dial kind. Override fragment: static constexpr bool kSelfHealLinks = true; — AND compile the TU (-DLIBTRACER_SELF_HEAL_LINKS=ON; the ESP-IDF CONFIG_LIBTRACER_SELF_HEAL_LINKS does both from one symbol). The core test build and the bench/ build opt in.

It is a REFUSAL, never a silent downgrade. A build that binds it false and then registers a self_heal_dial kind is rejected at register_transport_type: the kind is NOT catalogued, so a SPEC naming it answers SCHEMA_NOT_FOUND — the same answer any unregistered kind gets — and a debug build asserts at the registration itself. Quietly registering it with the trait CLEARED would be worse than either: the connection would come up eagerly, with no redial and no liveness publishing, and nothing would say so.

The built-ins are not subject to that refusal, and are not downgraded by it (maintainer ruling 2026-08-25, #1548): they read this knob at their OWN registration site (kBuiltinPointToPointTraits in builtin_transports.hpp) and declare self_heal_dial = false on a closed-out build, keeping the eager dial they always had. The refusal above targets a kind that CLAIMS an engine the image does not carry; a build-conditioned declaration claims nothing it cannot have. Making the refusal fire for the built-ins instead would drop udp/tcp/ws out of the catalog and turn the lean default into a broken build.

static constexpr std::size_t kSelfHealWorkerStackBytes = 0

Stack bytes for the link-liveness engine’s worker thread; 0 = the platform default (#1470).

The worker is the sole dialer and the sole liveness publisher, so its stack has to carry a connect() and the publish fan-out beneath it. On a POSIX host the platform default is megabytes of lazily-committed address space and nobody has to think about it. On an RTOS target it is a REAL allocation from the heap, per link: ESP-IDF’s std::thread is a pthread, and a pthread takes CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT from a board that may have ~76 KB free — 12,288 B per link on the sdkconfig #1470’s reporter measured. There was no way to size it, which made the engine unusable on such a target INDEPENDENTLY of whether the flash was affordable — the second half of #1470.

Applied through pthread_attr_setstacksize at the spawn, which is the same mechanism posix_endpoint_t::start and socketcan_link_t::start already use for their receive threads, and which IDF’s pthread honours. So it is not an ESP-only knob with an inert body elsewhere: glibc honours it too. A value below the platform floor makes pthread_attr_setstacksize return EINVAL and leaves the default in place — the spawn still happens, with the default stack, exactly as the two precedents behave.

Left at 0 — every host build, and the default everywhere — nothing is set and the spawn is byte-for-byte the one that shipped. Override fragment: static constexpr std::size_t kSelfHealWorkerStackBytes = 4096;

static constexpr std::size_t kRxDrainFrames = 32

Frames a link’s receive context may consume back to back before it waits for its core to go idle once; 0 = no frame budget (ADR-0085).

A receive context that always finds more bytes waiting never blocks, so on a core it shares with the idle task a peer that keeps sending keeps that core busy for as long as it likes. On ESP-IDF that is the IDLE task’s watchdog: the board resets. The budget bounds one DRAIN, which is the run of frames a receive context consumes without its core going idle in between. When it is spent, the link stops reading and waits until the core’s idle task has run. The unread bytes stay in the socket, the TCP window closes and the peer’s own stack throttles it (ADR-0081 §2): no frame is dropped, none is buffered by the library, and nothing measures time. The drain counter restarts whenever the core idles on its own, so a link that is not saturated never waits.

Its one reader is the ESP-IDF httpd_ws_link_t, whose receive context is the esp_http_server task. Hosted links read the socket from their own thread under a preemptive OS scheduler and do not consult it.

Default 32 — sized against the watchdog, not against throughput. The longest stretch the idle task can now be kept out is one budget of frames plus whatever higher-priority work follows it. At the ~7.3 ms of board time per small request measured on an ESP32-C6, 32 frames is about 0.23 s, under a quarter of the shortest task watchdog IDF offers (1 s) and a twentieth of its default (5 s). Each wait costs the time until the core idles, which on a link that is the only load is the time the Wi-Fi and TCP/IP tasks need to settle; per 32 frames that is a small share of the burst. A target whose frames are much dearer than that lowers it.

A non-zero budget requires that the core idles. The wait ends only when the idle task runs, and the idle task runs only once every other task on the core has blocked. So after one budget of back-to-back frames the receive context yields to EVERY lower-priority ready task on its core until all of them block: a priority inversion that bounds the link’s read rate under sustained load by the longest run of lower-priority work. A build whose core may never idle (one that runs a low-priority task that never blocks, and has turned the idle-task watchdog check off for it) must bind 0 here and in kRxDrainBytes, or the link stops reading for good after one budget. The ESP-IDF component defaults both to 0 unless the task watchdog watches the idle task of every core. Override fragment: static constexpr std::size_t kRxDrainFrames = 16;

static constexpr std::size_t kRxDrainBytes = 32768

Payload bytes a link’s receive context may consume back to back before it waits for its core to go idle once; 0 = no byte budget (ADR-0085).

The byte half of kRxDrainFrames, with the same reader, the same wait and the same reset. Frames are what a small-write flood costs; bytes are what a large-frame flood costs, where every frame is copied and decoded. A drain ends when EITHER budget is spent. The frame that crosses the line is read whole (a frame is never split), so one drain consumes at most this many bytes plus one frame. The same requirement holds: a non-zero value needs a core that idles (see kRxDrainFrames).

Default 32,768 — the ESP-IDF link’s own per-frame cap, so one maximum-size frame is one drain, and about six default lwIP receive windows. Override fragment: static constexpr std::size_t kRxDrainBytes = 16384;

static constexpr bool kInstrumentCounters = false

Whether tr::graph::graph_t carries its two INSTRUMENTATION counters — ancestor_walks() and target_canonical_resolves() (#1664).

Both are relaxed 64-bit fetch_adds on a node-wide counter that nothing in the library reads: no :stats noun, no wire surface, no decision. Only tests and benches consume them, as ablations proving a write took the leg its name claims — the bubbling walk (RFC-0005) and the target-edge canonical fallback (#830). Paying for them on a shipped node is pure loss, and the loss is not uniform: on rv32 a 64-bit atomic RMW is a libatomic call, taken once per write that has an ancestor subscriber and once per unbound target-edge delivery.

Default — the lean choice. Closed out, the two members occupy no bytes of graph_t ([[no_unique_address]] over an empty type), the two increment sites compile to nothing, and the accessors answer 0. It also arms the RFC-0022 §6 pin/copy branch counters of pin_instrument.hpp, which were the LIBTRACER_PIN_INSTRUMENT macro until #1722; that macro is now refused at compile time. A compile-time member rather than a macro for ADR-0068’s reason: a macro can differ per TU, and these counters change graph_t’s layout.

Who sets it. The core test build and the bench/ build’s LIBTRACER_INSTRUMENT_COUNTERS option opt in through the checked-in preset fragment core/tests/instrumented/libtracer/config_override.hpp. A test that asserts on either counter gates the assertion on this knob, so a CI leg binding its own fragment (which inherits false) still runs the rest of the test. Override fragment: static constexpr bool kInstrumentCounters = true;

static constexpr bool kFaultInjection = false

Whether the TEST-ONLY fault-injection hooks are compiled in (#1719): tr::detail::probe_fail_hook, tr::detail::write_fault_inject_hook, tr::net::detail::ws_peer_published_hook and tr::net::detail::tcp_peer_publishing_hook.

Each hook is a process-wide function pointer that a test arms to drive a path a healthy host never takes: a nothrow draw the heap refuses, a write errno glibc never returns, a handshake instant held open. Nothing in a shipped node ever arms one, yet each check it guards was a load and a branch on that node — the probe_fail_hook one on EVERY draw from the process-default block source and every nothrow growth.

Default — the lean choice. Closed out, every check is an if constexpr branch that compiles to nothing: the default allocation path carries no hook load, and no hook variable is odr-used, so none is emitted into the library. The declarations stay visible either way, so a test that arms a hook still compiles under a fragment that leaves this false; arming one there simply changes nothing.

Who sets it. The core test build, through its checked-in preset fragment core/tests/instrumented/libtracer/config_override.hpp, the fragment core/CMakeLists.txt writes for the deprecated -D knobs when that same test build passes them, and every CI leg that binds a fragment of its own and runs the fault-injection tests. Override fragment: static constexpr bool kFaultInjection = true;

static constexpr bool kAllowInsecureTls = false

Whether a connection SPEC may carry the insecure key of the quic and webtransport kinds — the dial-side switch that skips server-certificate verification.

insecure is a DEV-ONLY convenience for reaching a self-signed peer. A SPEC is a wire write, and on a build without an ACL policy any connected peer may write one, so honouring the key unconditionally would let a peer make this node dial with peer authentication switched off. Whether that key is honoured at all is therefore a decision the BUILD states, not the SPEC.

Default — the lean and safe choice. Closed out, a SPEC whose config carries insecure = nonzero is REFUSED at creation: the factory answers graph::status_t::PERMISSION_DENIED and counts the refusal (tr::net::quic_insecure_refusals() / tr::net::webtransport_insecure_refusals(), in the respective module header). It is never silently ignored — a dial that asked for no verification and quietly got verification would fail later for a reason nobody wrote down — and never honoured. insecure = 0 is the explicit “verify” spelling and is accepted either way. An app TLS profile whose ca_file certifies the peer (selected by the SPEC’s tls key, see tr::net::tls_profile_t) is the way to reach a privately-issued or self-signed peer on a closed-out build.

Only the SPEC path is gated. An application that constructs a transport itself with quic_dial_tls_t{.insecure_no_verify = true} has made that choice in its own C++; no peer can reach that field.

Who sets it. A development or test build that dials self-signed peers through a SPEC. The quic CI workflow runs the QUIC and WebTransport tests a second time under the checked-in fragment core/tests/insecure-tls/libtracer/config_override.hpp. Override fragment: static constexpr bool kAllowInsecureTls = true;

using tr::graph::config_t = default_config_t

THE configuration this build uses — the one binding, and the one thing to override.

An override fragment binds this alias to its own traits type; with no fragment present it names default_config_t. Everything below is derived from it, so nothing else changes.

constexpr std::size_t tr::graph::kVertexLockStripes = config_t::kVertexLockStripes

default_config_t::kVertexLockStripes for this build.

constexpr std::size_t tr::graph::kCacheLineBytes = config_t::kCacheLineBytes

default_config_t::kCacheLineBytes for this build.

constexpr std::size_t tr::graph::kGuardStripes = config_t::kGuardStripes

default_config_t::kGuardStripes for this build.

constexpr std::size_t tr::graph::kHazardReaderSlots = config_t::kHazardReaderSlots

default_config_t::kHazardReaderSlots for this build.

constexpr std::size_t tr::graph::kInlineFanout = config_t::kInlineFanout

default_config_t::kInlineFanout for this build.

constexpr std::size_t tr::graph::kShareThresholdBytes = config_t::kShareThresholdBytes

default_config_t::kShareThresholdBytes for this build.

constexpr bool tr::graph::kInstrumentCounters = config_t::kInstrumentCounters

default_config_t::kInstrumentCounters for this build.

constexpr bool tr::graph::kFaultInjection = config_t::kFaultInjection

default_config_t::kFaultInjection for this build.

using tr::graph::acl_policy_t = config_t::acl_policy_t

default_config_t::acl_policy_t for this build.

using tr::graph::lkv_slot_t = config_t::lkv_slot_t

default_config_t::lkv_slot_t for this build.

using tr::graph::guard_t = detail_guard::sized_guard<config_t>::type

The critical-section guard for this build: default_config_t::guard_t, with the inherited host guard sized by kCacheLineBytes and kGuardStripes (#1716).

using tr::graph::reclaim_policy_t = config_t::reclaim_policy_t

default_config_t::reclaim_policy_t for this build.

constexpr std::size_t tr::graph::kDeferredReleaseSlots = config_t::kDeferredReleaseSlots

default_config_t::kDeferredReleaseSlots for this build.

constexpr std::size_t tr::graph::kQsbrParticipants = config_t::kQsbrParticipants

default_config_t::kQsbrParticipants for this build.

The derived constants that are not in tr::graph: the memory layer and the transport plane read their own spellings, so that neither L0 nor tr::net has to name an L4 type.

constexpr bool tr::mem::kSpinWaitSafe = tr::graph::config_t::kSpinWaitSafe

Whether a task on this target may SPIN-WAIT for a lock another task holds.

The memory layer’s spelling of tr::graph::default_config_t::kSpinWaitSafe, which carries the full rationale. Derived from tr::graph::config_t exactly as the tr::graph loose names are, so an override fragment sets it in the one place every knob is set.

A target fact, so the BUILD states it and nothing asks the integrator (the same reasoning that derives tr::graph::kCacheLineBytes rather than exposing it). Its one consumer is the guard in synchronized_pool_t, which refuses to instantiate the spinlock policy where spin-waiting is unsafe.

constexpr std::size_t tr::mem::kDeviceBackendSlots = tr::graph::config_t::kDeviceBackendSlots

How many DEVICE-space backends may register a transfer hook at once.

The memory layer’s spelling of tr::graph::default_config_t::kDeviceBackendSlots, which carries the full rationale. Derived from tr::graph::config_t exactly as kSpinWaitSafe is, so an override fragment sets it in the one place every knob is set. Its one consumer is the bounded table behind register_device_backend (device_backend.cpp).

constexpr bool tr::net::kBusLinks = tr::graph::config_t::kBusLinks

Whether this target carries the ADR-0044 BUS facet at all (peer-named links).

The transport plane’s spelling of tr::graph::default_config_t::kBusLinks, which carries the full rationale, the measured saving and the refusal rule. Derived from tr::graph::config_t exactly as tr::mem::kSpinWaitSafe is, so an override fragment sets it in the one place every knob is set. Its consumers reach it through tr::net::bus_of (transport.hpp) rather than reading it directly.

constexpr bool tr::net::kSelfHealLinks = tr::graph::config_t::kSelfHealLinks

Whether this target carries the RFC-0014 §4 S5 link-liveness engine at all.

The transport plane’s spelling of tr::graph::default_config_t::kSelfHealLinks, which carries the full rationale, the measured saving and the refusal rule. Derived from tr::graph::config_t exactly as kBusLinks is, so an override fragment sets it in the one place every knob is set.

constexpr bool tr::net::kAllowInsecureTls = tr::graph::config_t::kAllowInsecureTls

Whether a connection SPEC may carry the dial-side insecure key.

The transport plane’s spelling of tr::graph::default_config_t::kAllowInsecureTls, which carries the rationale. Derived from tr::graph::config_t exactly as kBusLinks is, so an override fragment sets it in the one place every knob is set. Its consumers are the quic and webtransport factories.

constexpr std::size_t tr::net::kSelfHealWorkerStackBytes = tr::graph::config_t::kSelfHealWorkerStackBytes

Stack bytes for the link-liveness engine’s worker thread; 0 = platform default.

The transport plane’s spelling of tr::graph::default_config_t::kSelfHealWorkerStackBytes, which carries the rationale. Its one consumer is the pthread_attr_setstacksize at the worker spawn (self_heal_link.cpp).

constexpr std::size_t tr::net::kRxDrainFrames = tr::graph::config_t::kRxDrainFrames

Frames a receive context consumes back to back before it waits for its core to idle; 0 = no frame budget.

The transport plane’s spelling of tr::graph::default_config_t::kRxDrainFrames, which carries the rationale. Its one consumer is the ESP-IDF httpd_ws_link_t.

constexpr std::size_t tr::net::kRxDrainBytes = tr::graph::config_t::kRxDrainBytes

Payload bytes a receive context consumes back to back before it waits for its core to idle; 0 = no byte budget.

The transport plane’s spelling of tr::graph::default_config_t::kRxDrainBytes, which carries the rationale. Its one consumer is the ESP-IDF httpd_ws_link_t.

The selectable policies

struct allow_only_policy_t

The required-modules MCU profile policy (ADR-0020 core subset): ALLOW-only.

Any applicable ACE grants — order is irrelevant because DENY does not exist in this profile (a :acl write carrying one is rejected at parse time).

Public Static Functions

static inline acl_verdict_t allows(std::span<const std::byte> subject, std::uint32_t bit, std::span<const ace_t> aces, std::uint64_t now, std::uint8_t required_flags = 0) noexcept

Evaluate one ACE list — pure: no locks, no clock, no graph access.

Parameters:
  • subject – The resolved subject token bytes (ADR-0018).

  • bit – The requested right (one acl_right_t bit).

  • aces – One vertex’s stored ACEs, in stored order.

  • now – Check-time wall clock, ns since the UNIX epoch.

  • required_flags – ACEs lacking these flag bits are skipped — 0 for the target’s own list, kAceInherit for an ancestor’s.

Returns:

ALLOW or NO_MATCH (this profile never returns DENY).

Public Static Attributes

static constexpr bool kAcceptsDeny = false

This profile rejects DENY ACEs at parse time.

struct full_acl_policy_t

The security_acl host policy (ADR-0020 full model): ordered first-match-per-bit with DENY.

For the requested bit, the FIRST applicable ACE in stored order decides — ALLOW or DENY per its type (NFSv4 evaluation). The graph calls this per effective-ACE list, own list before ancestors, so cross-list ordering follows the effective-ACL definition of ADR-0020.

Public Static Functions

static inline acl_verdict_t allows(std::span<const std::byte> subject, std::uint32_t bit, std::span<const ace_t> aces, std::uint64_t now, std::uint8_t required_flags = 0) noexcept

Evaluate one ACE list — pure: no locks, no clock, no graph access.

Parameters:
  • subject – The resolved subject token bytes (ADR-0018).

  • bit – The requested right (one acl_right_t bit).

  • aces – One vertex’s stored ACEs, in stored order.

  • now – Check-time wall clock, ns since the UNIX epoch.

  • required_flags – ACEs lacking these flag bits are skipped — 0 for the target’s own list, kAceInherit for an ancestor’s.

Returns:

ALLOW or NO_MATCH (this profile never returns DENY).

Public Static Attributes

static constexpr bool kAcceptsDeny = true

The full model stores and evaluates DENY ACEs.

class single_writer_slot_t : public tr::graph::basic_single_writer_slot_t<guard_t>

basic_single_writer_slot_t over this build’s tr::graph::guard_t — the name an override fragment binds.

A class rather than an alias so config.hpp can forward-declare it: the fragment names the slot before this header has been seen, and the guard it will use is a member of the very traits type the fragment is defining.

template<::tr::guard G>
class basic_single_writer_slot_t

The slot for a single-writer build (RFC 0028 §5.5): one value_t*, exchanged and retained inside the guard G, which never spins.

Why it exists (#1618). The refcount slot this replaced, std::atomic<std::shared_ptr>, is spin-locked in libstdc++: load and store take a pointer-lock bit and a contender spins on it with sched_yield. On a priority-preemptive single-core scheduler, sched_yield yields only to equal or higher priority, so a high-priority reader that preempts a low-priority writer inside that window spins until the task watchdog fires. Here the window is a guard that cannot be spun on: an interrupt-masked critical section cannot be preempted at all, and the host’s mutex_guard_t puts a contender to sleep once it has spun out.

What the guard covers, and what it does not. store swaps the pointer inside the guard and releases the displaced value AFTER leaving it, so a value’s link destructors and its block source never run with interrupts masked. load retains inside the guard, so the refcount increment cannot interleave with the writer’s release. Both sections are a handful of instructions and call nothing that can block.

Writers are serialized too. Nothing here relies on a single publisher: two writers serialize on the guard like a writer and a reader do, so a build that publishes one vertex from two threads is still memory-safe. The name comes from RFC 0028 §5.5, where the single-writer build is the one that must bind it.

Why one publisher would not let the writer skip the guard, even now the slot is one word. It is tempting: one publisher, so nothing to exclude on the write side, publish with a single atomic exchange and let readers acquire. The slot IS one word since RFC 0028 slice 3 (an intrusive value_t*), so the torn two-word swap the shared_ptr slot had is gone — but the race that matters never needed two words. A reader inside its guard has loaded the pointer and not yet retained it; a writer that exchanges outside the guard goes on to release the displaced value, and if that was the last reference the block is freed under the reader’s retain. The reader’s guard excludes the writer only if the writer’s exchange is inside a guard too: exclusion is pairwise, and the single-writer contract says nothing about readers. So the writer keeps the guard on every target, which is why the per-build kSingleWriter promise was removed (#1718): it had nothing left to unlock. (RFC 0028 §5.5’s sentence that a reader’s retain inside its guard “cannot interleave with the writer’s release” once the slot is one word is the claim this paragraph corrects.) lkv_slot_test’s one-writer / N-reader run is the test that bites when this is tried: with the writer’s guard removed, a reader reads a value after its free (ASan: heap-use-after-free).

Template Parameters:

G – A tr::guard; the slot takes G::for_address(this). The bound slot uses config_t::guard_t; tests instantiate this template directly with a guard of their own.

Public Types

using guard_type = G

The guard the slot’s sections take — what publishes_under reads (#1715).

Public Functions

inline bool store(value_t *v, std::memory_order = std::memory_order_seq_cst) noexcept

Publish. The swap happens inside the guard; the displaced value is released after it, outside.

No fence follows the guard. What vertex_t::store needs from the slot is that the value is visible to anyone who observes the next write_seq_ bump, and the bump is a seq_cst read-modify-write sequenced after the guard’s release, so it already carries the swap. The waiterless-publish argument (#555) is about write_seq_ and the waiter count only.

Parameters:

v – The value to publish; the slot adopts the caller’s reference. Null clears.

Returns:

Always true. A swap allocates nothing, so there is no failure to report.

template<class F>
inline bool store(value_t *v, F &&in_section) noexcept

Publish, and run in_section inside the SAME section as the swap (#1715).

The fused publish of a core with no atomic read-modify-write: there the write sequence’s bump is itself a section of G (tr::rmw_counter_t’s guarded binding), so publishing through store and then bumping opened two sections back to back. Passing the bump in here (rmw_counter_t::bump_in_section) opens one. The caller must name this slot’s address as the anchor of every OTHER bump of that counter, so that every bump still serializes on one guard.

Publication, restated for the fused form. store’s note relies on the bump being a seq_cst RMW sequenced after the guard’s release. Here the bump is a plain load and a seq_cst store, made inside the guard, after the swap. A reader that observes the new sequence value and then reads the slot still sees the swap: its load() takes the same guard, and its section cannot come first, because the reader’s read of the sequence is sequenced before its section opens, while the store it read is sequenced after this section opened. A load cannot read a store that happens after it. So the reader’s section follows this one, and the guard’s release/acquire carries the swap to it. The writer’s Dekker pair is unchanged: the bump is still a seq_cst store, sequenced before the waiters load the vertex makes after this returns.

Parameters:
  • v – The value to publish; the slot adopts the caller’s reference.

  • in_section – Called once, inside the guard, after the swap. Must not throw, block or allocate: it runs with interrupts masked on an RTOS chip.

Returns:

Always true, as store.

inline void clear(std::memory_order = std::memory_order_seq_cst) noexcept

Drop the published value. Releases a reference outside the guard; cannot fail.

inline value_ref_t load() const noexcept

Read the published value: one guarded retain.

The retain is the refcount increment that lets the handle outlive the guard, and it is the only work inside it.

inline ~basic_single_writer_slot_t()

Release whatever is still published. No reader can be inside the guard by now.

Public Static Attributes

static constexpr bool may_spin = G::may_spin

This policy’s only wait is its guard, so it spins exactly when the guard does.

template<std::size_t LineBytes, std::size_t Stripes>
struct basic_mutex_guard_t

The host guard: a one-word lock, and a table of them striped by address.

On a single-core RTOS the guard is an interrupt-masked critical section (the ESP-IDF component binds tr::esp::critical_guard_t), which makes the window unpreemptable. A host process cannot mask interrupts, so this is the host’s spelling of the same promise: a contender that finds the window held gives the CPU back to whoever holds it, instead of spinning until the holder is scheduled again.

Public Functions

inline void lock() noexcept

Take the lock: one RMW, and the out-of-line wait only when it was held.

inline void unlock() noexcept

Give the lock back: a release store, and nobody to notify (see the class).

Public Static Functions

static inline basic_mutex_guard_t &for_address(const void *at) noexcept

The stripe the address at hashes to: the top bits of a Fibonacci hash.

Public Static Attributes

static constexpr std::size_t kAlign = std::max(LineBytes, alignof(std::atomic<bool>))

Each lock’s alignment: LineBytes, raised to the flag’s natural alignment.

static constexpr std::size_t kStripes = Stripes

Stripes in the process-wide table for_address draws from.

static constexpr unsigned kSpinsBeforeNap = 128

Re-reads of a held flag before a contender sleeps; covers a cross-core release.

static constexpr std::chrono::microseconds kNap = {20}

How long a contender sleeps between looks once it has spun out.

Only a holder descheduled inside its few-instruction window makes anyone sleep, so this bounds the extra latency of that rare case, not the common one. Bounded from below by what one nanosleep costs anyway.

static constexpr bool is_isr_safe = false

A host thread lock, never an ISR’s.

static constexpr bool is_nonblocking = false

A contender that spins out SLEEPS — an OS wait (#928).

static constexpr bool may_spin = false

The wait naps rather than spins, so it is safe on any target (see the class).

static constexpr const char *name = "mutex_guard"

Census name.

typedef basic_mutex_guard_t<64, 64> tr::mutex_guard_t

The host guard at its default sizing: 64 stripes, each padded to a 64-byte line.

default_config_t::guard_t names it; a build that inherits that binding gets the guard re-sized from its own kCacheLineBytes and kGuardStripes (see config.hpp).

struct no_guard_t

The guard that guards nothing — for a build that is single-threaded by contract, and the default policy of a pool one thread owns.

A build that binds hazard_slot_t never opens a guard, so this is never taken there. A build that binds single_writer_slot_t (or a pool) over this guard is asserting that no other thread ever touches the guarded state concurrently — true of a single-threaded program, and of a tr::mem::pool_source_t whose one owner is the only thread that draws from it (ADR-0067). It is empty, so [[no_unique_address]] erases it.

Public Functions

inline void lock() noexcept

No section to open.

inline void unlock() noexcept

No section to close.

Public Static Functions

static inline no_guard_t &for_address(const void*) noexcept

The one instance every address shares; it holds nothing.

Public Static Attributes

static constexpr bool is_isr_safe = false

Guards nothing, so promises nothing.

static constexpr bool is_nonblocking = true

Does nothing, so never waits.

static constexpr bool may_spin = false

Never waits at all.

static constexpr const char *name = "no_guard"

Census name.

struct reclaim_strict_t

**reclaim_strict** — the grace point is the moment unsubscribe() returns.

The opt-in ZERO-COST mode (ADR-0080 §Decision 2), for an MCU deployment that provably never unsubscribes from inside a dispatch. Nothing is tracked on the dispatch path, no state is held anywhere, and unsubscribe() runs the release hook inline before it returns — so the caller may free its context on that return with no further ceremony.

Re-entrant unsubscribe is FORBIDDEN, not merely discouraged: unsubscribing from inside a delivery would retire a pair the running fan-out’s snapshot still names. A debug build asserts on it (see graph.cpp); an NDEBUG build cannot see it, which is the trade this policy exists to make. Select it only where the deployment can show that re-entrant unsubscribe does not occur — otherwise take the default, which supports it.

Selecting it is one line in libtracer/config_override.hpp: using reclaim_policy_t = reclaim_strict_t;

Public Static Attributes

static constexpr std::string_view kName = "reclaim_strict"

The policy’s name, for a diagnostic that must say which one is bound.

static constexpr bool kDefersToDispatchExit = false

Whether a retired pair may be DEFERRED past unsubscribe()’s return.

False here: there is no grace period to defer into, so unsubscribe() releases inline and the dispatch path carries nothing at all.

static constexpr bool kReentrantUnsubscribe = false

Re-entrant unsubscribe() (from inside a delivery) is not supported.

static constexpr bool kGraceSpansThreads = false

Whether the grace point is stated over EVERY thread rather than one.

False here: there is no grace period at all, so there is nothing for a second thread to be inside. See reclaim_qsbr_t for the policy that answers true and what changes.

struct reclaim_local_t

**reclaim_local** (the DEFAULT) — the grace point is the moment this thread’s dispatch stack unwinds to depth 0.

It is the default because it makes the MCU and the host build behave IDENTICALLY (ADR-0080 §Decision 1). reclaim_strict would make the same application code legal on a host that tolerates re-entrant unsubscribe and illegal on the constrained target — a portability bug that surfaces only where it is hardest to debug.

What a caller gets

Exactly one of two things, and the library decides which — the caller never asks:

  1. **unsubscribe() was called from outside any delivery** (dispatch depth 0 — the ordinary case). No delivery to that context can be in flight on this thread, so the release hook runs INLINE and unsubscribe() returns already quiescent. This is reclaim_strict’s guarantee, delivered at reclaim_strict’s cost, for the case that dominates.

  2. **unsubscribe() was called from INSIDE a delivery** (a subscriber callback unsubscribing itself or a sibling). The running fan-out is walking a snapshot that still names the retired pair, so the pair is PARKED and the hook runs when the outermost delivery on this thread returns — i.e. before the write() / propagate() that started it hands control back.

In both cases the signal is the hook. There is no poll, no wait, and no verb the embedder must remember to call.

The scope of the guarantee

It is stated over one thread’s dispatch domain, which is the WIDE / MCU target this policy is for: a single-threaded node, where publish and unsubscribe() cannot overlap because there is no second thread to overlap with. An embedder that dispatches from several threads concurrently and unsubscribes from another needs a grace period spanning every thread — that is reclaim_qsbr_t, ADR-0080’s third policy (#894, #1376), not this one.

What it costs

One non-atomic increment, decrement and branch per fan_out — regardless of subscriber count — on a per-thread counter, so no cache line is ever shared and no atomic is involved. Parking allocates nothing: the retired pairs sit in a bounded per-thread array sized by tr::graph::default_config_t::kDeferredReleaseSlots.

Public Static Attributes

static constexpr std::string_view kName = "reclaim_local"

The policy’s name, for a diagnostic that must say which one is bound.

static constexpr bool kDefersToDispatchExit = true

Whether a retired pair may be DEFERRED past unsubscribe()’s return.

True here: a re-entrant unsubscribe parks its pair and the outermost dispatch’s exit releases it. The deferral happens ONLY at depth > 0 — at depth 0 the release is inline.

static constexpr bool kReentrantUnsubscribe = true

Re-entrant unsubscribe() (from inside a delivery) is supported.

static constexpr bool kGraceSpansThreads = false

Whether the grace point is stated over EVERY thread rather than one.

False here, and it is the ONE limitation of this policy: the grace point is this thread’s dispatch stack, so a sibling thread’s in-flight fan-out is invisible to it. See The scope of the guarantee.

struct reclaim_qsbr_t

**reclaim_qsbr** — the grace point is the moment EVERY dispatching thread has passed a quiescent state (#1376).

ADR-0080’s third policy and the MID / NARROW many-core one: bind it when this node dispatches from several threads at once and may unsubscribe from a thread other than the one delivering. That is the single case neither shipped policy covers — reclaim_local_t’s grace point is one thread’s dispatch stack, so a sibling thread’s live snapshot is invisible to it, and reclaim_strict_t has no grace period at all.

Selecting it is one line in libtracer/config_override.hpp: using reclaim_policy_t = reclaim_qsbr_t;

What a caller gets

A retired {fn, ctx} pair is released once no thread can still be walking a snapshot that names it. As under reclaim_local_t the caller never polls and never waits; it registers a subscriber_release_fn_t and is told. Two cases, and again the library picks:

  1. No participant is mid-dispatch — every ordinary unsubscribe on a node that is not concurrently publishing. The scan concludes immediately, the hook runs INLINE, and unsubscribe() returns already quiescent. This is the same property (a) the other two policies deliver, and it still dominates.

  2. Some participant IS mid-dispatch. The pair is deferred and released by whichever participant next completes the grace period.

The one API difference, stated rather than hidden

In case 2 the hook runs on a thread other than the caller — specifically on whichever participant’s quiescence completed the grace period, or on the caller’s own next dispatch exit, whichever comes first. Both other policies promise the caller’s own thread; a grace period that spans threads structurally cannot, because the only alternatives are to block the caller (ADR-0080 §Decision 4 rejects waiting outright) or to leak the pair.

So a release hook under this policy must be thread-safe with respect to its own context. That is a real widening of the contract and it is why this is an opt-in policy rather than the default: on the single-threaded target reclaim_local_t serves, the distinction does not exist, and ADR-0080 §Decision 1’s parity argument keeps the default where it is.

What it costs

Per OUTERMOST fan_out — never per edge, and nothing at all on a publish nobody subscribed to, because the bracket sits below fan_out’s no-subscriber gate:

  • entry: one RELAXED load of a read-mostly shared line (the epoch, bumped only on the control plane) and one seq_cst store to this thread’s own cache-line-isolated cell. No atomic read-modify-write;

  • exit: one release store to that same cell, plus one relaxed load of a read-mostly count that is 0 on any node not mid-teardown, and the predictable branch it guards.

The O(kQsbrParticipants) scan is on the RECLAIM path only — that is the precise difference from the shape #635 rejected, which put a hazard scan on the READ path. Storage is tr::graph::default_config_t::kQsbrParticipants cache-line-isolated cells plus one shared table of tr::graph::default_config_t::kDeferredReleaseSlots retired pairs, all .bss, and none of it emitted into a build that binds a different policy.

What it discharges for #897

ADR-0080 §”#897 maps onto the same seam” asks that each thread self-drain its own retired LKV list at its own quiescent point, so ~hazard_slot_t never has to reach across a live thread’s private list. Under this policy that is exactly what happens, and it costs lkv_slot.hpp no code at all: the quiescent point calls the already-shipped tr::graph::detail_hp::retire_and_flush(nullptr), whose cheap early-out makes it free on a thread that parked nothing. No store()-path atomic is added.

Public Static Attributes

static constexpr std::string_view kName = "reclaim_qsbr"

The policy’s name, for a diagnostic that must say which one is bound.

static constexpr bool kDefersToDispatchExit = true

Whether a retired pair may be DEFERRED past unsubscribe()’s return.

True: this policy needs the very same dispatch bracket reclaim_local_t does — the transition to depth 0 IS the quiescent state it announces — and defers whenever the scan finds a participant that has not yet reached one.

static constexpr bool kReentrantUnsubscribe = true

Re-entrant unsubscribe() is supported — subsumed by the grace period.

static constexpr bool kGraceSpansThreads = true

The grace point is stated over EVERY dispatching thread. See The one API difference, stated rather than hidden for what that changes for a release hook.

struct retired_callback_t

One retired subscription’s release obligation — the {ctx, hook} pair a policy that defers must hold until its grace point.

Trivially copyable and free of any owning member, so a bounded array of these is plain storage with no destructor and no initialization guard (which is what lets reclaim_local_t’s per-thread parking allocate nothing, ever).

Public Members

void *ctx = nullptr

The subscriber’s own context.

subscriber_release_fn_t release = nullptr

What to call on it, exactly once.

class hazard_slot_t

The host slot (ADR-0069 §1): a lock-free atomic<node_t*> reclaimed with hazard pointers, returning the same owning value_ref_t single_writer_slot_t does.

Why this exists: today’s slot INVERTS under concurrent readers — measured through the real path, graph_t::read on one shared LKV falls from 21.1 M/s at one reader to 1.7 M/s at twenty-four, because both load and store take libstdc++’s _Sp_locker pointer-lock bit. Hazard reclamation deletes that lock; what it cannot delete is the control-block increment an owning read still owes. End to end that is worth 4.2× at twenty-four readers (7.4 M/s) — see the table in this file’s header, and ADR-0069 §6 for why the model bench’s 20.8× did not survive contact with the whole read path.

A host opt-in. The gain over the refcount slot it replaced is a concurrency gain, so a single-core node buys nothing from it and still pays (kHazardReaderSlots + 1) * 128 bytes of registry, a deferred-reclamation lifetime rule (see retire_and_flush), and a publish that can fail. Such a node binds single_writer_slot_t.

Publish can fail under memory exhaustion, which single_writer_slot_t cannot: an empty free list makes the first publish per participant allocate a 16-byte node. It is reported, not silent — store returns false and vertex_t::store turns that into the same nullptr → BACKPRESSURE soft-fail an LKV allocation failure already produces (#477), so no write is ever reported as taken when it was not. Every later publish reuses the node its own displacement recycled, so the window is a warm-up one — but it is still a real difference in the policy’s failure surface, and a third reason the MCU does not bind this slot. Note also that the node comes from the global heap, not from a graph’s injected block_source_t: the slot policy is never handed one, and a bounded target that needs every byte accounted for is another target that should bind single_writer_slot_t.

It does not spin-wait. The one loop in the domain that waits on another thread is the overflow index’s lock in detail_hp::ticket_t, and it waits with atomic_flag::wait, which blocks (a futex, or libstdc++’s pooled condition variable) after a bounded spin. The read’s announce-and-revalidate loop retries only when a publish moved the slot, which is progress.

Public Functions

inline ~hazard_slot_t()

Retire the published node rather than free it — a reader may still be pinning it — and flush, so the value cannot outlive the memory it was allocated from.

A slot that was never written costs nothing here: no node, no flush, no domain access.

inline bool store(value_t *v, std::memory_order order = std::memory_order_seq_cst) noexcept

Publish, sequentially consistent unless the caller says otherwise.

A null value is not a publish — use clear. On success the node adopts the caller’s reference; on false the reference is still the caller’s.

Returns:

false if no node could be obtained for the value, in which case nothing was published and the previous value still stands. Only a participant’s first publish can reach that: every later one reuses the node its own displacement recycled, so the free list makes the steady state allocation-free.

inline void clear(std::memory_order order = std::memory_order_seq_cst) noexcept

Drop the published value. Cannot fail — it publishes nullptr, which needs no node, so a clear allocates nothing even on a cold participant.

inline value_ref_t load() const noexcept

Read the published value.

Announce, re-read, then retain the value out of the pinned node — the retain is the promotion that lets the handle outlive the pin, and it is the one shared-cache-line RMW this scheme cannot remove. A slot nobody has written costs a single acquire load and never touches the domain at all.

The announce and the re-read are both seq_cst so that both sit in one total order with the publisher’s exchange and the reclaimer’s fence: if a scan did not observe this announcement, then in that order the scan’s read precedes it, the displacement precedes the scan, and so this re-read must observe the displacement and retry. acquire on the re-read is the usual spelling and is believed sound, but it leaves the argument resting on coherence rather than on the total order — and it costs nothing to close, since a seq_cst load is a plain mov on x86-64.

Reusing a node is deliberately allowed to ABA: a reader can pin n, have it reclaimed and republished, and revalidate against the same address. That is not a bug — n is live and holds a value some writer published, which is all a read promises.

Public Static Attributes

static constexpr bool may_spin = false

No operation spin-waits on another thread; see the class comment.

The runtime settings reader

class config_reader_t

Typed accessors over a positional-pair TLV’s children — a SPEC config SETTINGS, a SUBSCRIBER QoS SETTINGS, or the creation-SPEC envelope itself.

The layout is positional NAME-key / value pairs: a NAME child carrying the key string, immediately followed by the value child — a NAME for string values, a VALUE for integers/flags, or a nested SETTINGS for a module namespace. Unknown keys are ignored (forward-compat), a key whose value child has the wrong type (or a VALUE payload that is not EXACTLY the accessor’s width — a u32 asked of a 2-byte payload is absent, not zero-extended, #928) is ignored too, and when a key appears more than once the LAST well-formed occurrence wins.

The walk is pair-consuming: it advances a whole pair at a time, so an unknown key is skipped together with its value and no value child can ever be re-read as a key (#927). Forward-compat tolerance is deliberate here, and is the OPPOSITE ruling from the graph::parse_acl walk (#906), which is to REJECT an unknown key: config is where a newer peer legitimately sends more than a receiver understands, whereas an ACL is a security document in which a silently dropped attribute widens access.

Note

The returned string_views, spans and nodes (and the reader itself) borrow the frame bytes the node was validated over (tlv_node_t::over) — use them while those bytes are alive. The reader walks the children in place and allocates nothing (#1829).

Public Functions

inline explicit config_reader_t(const tlv_node_t *config) noexcept

Construct over config's children.

Parameters:

config – The validated pair-container node; nullptr = no config (every accessor returns nullopt). The node is copied (it is two words of borrowed bytes); the bytes it borrows must outlive the reader.

inline std::optional<std::string_view> name(std::string_view key) const noexcept

The string value of key (a NAME value child), if present.

inline bool has(std::string_view key) const noexcept

Whether key appears as a key in any well-paired position, whatever its value’s type or width.

The presence test for a key a reader must REFUSE rather than skip (a retired key whose silent omission would change behaviour): unlike the typed accessors, a value of the wrong type still counts. Same pair-consuming walk as find(), so a value child spelling key is never mistaken for it.

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

The raw payload bytes of key (a NAME value child), if present.

The byte-span twin of name() for a value that is a wire segment rather than text — graph_t::create_child reads the creation SPEC’s “name” this way, because the child name is appended to the parent’s key verbatim.

inline std::optional<tlv_node_t> settings(std::string_view key) const noexcept

The nested SETTINGS value child of key, or nullopt.

A module namespace (“config” in the creation SPEC, a per-transport block in a connection config). A node over the same borrowed bytes, same as every accessor.

inline std::optional<std::uint8_t> u8(std::string_view key) const noexcept

The u8 value of key (a 1-byte VALUE child), if present.

inline std::optional<std::uint16_t> u16(std::string_view key) const noexcept

The u16 value of key (a 2-byte VALUE child), if present.

inline std::optional<std::uint32_t> u32(std::string_view key) const noexcept

The u32 value of key (a 4-byte VALUE child), if present.

inline std::optional<bool> flag(std::string_view key) const noexcept

The boolean value of key: a 1-byte VALUE child read as u8, nonzero = true.

See: the configuration space (what each knob costs), security & ACL, graph, fwd-router (which consumes the settings reader).