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 — with tr::net::config_reader_t kept as the transport plane’s alias, 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 lkv_slot_t = tr::graph::hazard_slot_t;        // many-core: no shared pointer lock
};
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 policyacl_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 policylkv_slot_t picks how a vertex publishes and reads its last-known value. sp_atomic_slot_t is the default: an std::atomic<std::shared_ptr<const rope_t>> whose reclamation is the refcount, so there is no scheme to implement and no registry to size. hazard_slot_t is the many-core alternative: a lock-free atomic<node_t*> reclaimed with hazard pointers, which removes the pointer-lock both load and store take in the default slot. The trade is explicit — the hazard slot buys nothing at one thread, costs a fixed registry sized by kHazardReaderSlots, and has a publish that can fail under memory exhaustion, which the default slot cannot. Both return an owning handle from load(); that is the contract a third policy would have to satisfy.

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 decoded TLV’s storage; both die with the tlv_t.

  • 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 lkv_slot_t = sp_atomic_slot_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. The CMake option LIBTRACER_ACL_FULL=ON rebinds this to the full security_acl host policy (ordered first-match-per-bit with DENY) — a target-configuration change, never an edit to graph.cpp.

using lkv_slot_t = sp_atomic_slot_t

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

How a vertex publishes and reads its last-known value. Default: sp_atomic_slot_t, the std::atomic<std::shared_ptr<const rope_t>> libtracer has always used, whose reclamation is the refcount and whose registry cost is zero — the right choice for a write-dominated single-core node, and the reason a raw -I consumer builds what it always built.

A many-core host is the case for rebinding this: today’s slot INVERTS under concurrent readers, and a reclamation scheme that does not serialize recovers roughly 4x of that at twenty-four readers (ADR-0069 §6 — the real path, not the model bench’s 20.8x). 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.

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 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 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 = 96

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: 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 = 72

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 72 B on rv32 (-Os -fno-exceptions -fno-rtti, rv32imac_zicsr_zifencei/ilp32), identical across all three configuration legs.

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::uint32_t kPinPayloadRatio = 0

The RFC-0022 §3.D pin/copy amplification ratio — pin the written value as a subview of the inbound frame iff payload_bytes * K >= segment_bytes (and the payload is trailer-less).

K is not a synthetic limit: both branches are correct and K selects which correct branch is cheaper. Pinning holds the whole owning RX segment for the value’s lifetime, so K bounds the waste at (K-1)x the payload where an absolute byte threshold bounded it not at all.

segment_bytes is the ALLOCATED size of the segment the pin would keep alive, not the length of the delivered frame view. Those differ by a lot on a real transport: udp_transport_t receives every datagram into a kMaxDatagram-sized segment and delivers a length-n window over it, so a 1 KB datagram pins 64 KB. Measuring the view length instead would price a cost nobody pays and ignore the one everybody does.

kPinNever (0) is the reserved sentinel: never pin. It is the value shipped here, and it reproduces the pre-RFC default behaviour exactly (the old absolute threshold defaulted to 0 and its predicate required > 0). RFC-0022 §8 Q3 was answered by §6’s measurement (Amendment 2, PR #771): the sentinel is the landing default on BOTH targets — the on-by-default flip does not land.

The borrow is APP-OWNED policy, and this constant is the decision surface

A pinned value borrows its inbound RX segment for its whole lifetime — not for the delivery window, for as long as the value is the vertex’s last-known value. On a pooled RX backend that borrow is a pool slot, i.e. receive capacity, unavailable to the transport until the value is displaced or the vertex dies. The library makes the deferred release safe — atomic segment refcounts, so a borrow outliving the recv frame is never a use-after-free — but nothing in the library bounds the budget, and nothing can: only the application knows its pool geometry and its retention pattern.

So the quantity to size against is live pinned values x segment_bytes. K bounds the waste per value; it does not bound the number of values, which is why no K is a remedy for a retain-heavy workload — measured, see bench/README.md

§”RFC-0022 §6 —

receive-pool occupancy”: at the ESP32-C6 RX geometry every

K that pins at all collapsed a 29-slot pool identically once the live vertex count crossed the slot count.

Target-class guidance, and the reason the shipped value is the sentinel on both:

  • NARROW — set the sentinel. A fixed, small RX pool cannot fund an indefinite borrow; the same off-by-default-on-NARROW posture as the RFC-0027 label table.

  • WIDE / MID — may borrow freely; the pool is large relative to the retained set, and the borrow is the zero-copy latency win.

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.

static constexpr bool kWeaklyOrdered = true

Whether this target’s memory model may REORDER a later relaxed load ahead of an earlier seq_cst store — i.e. whether it is anything WEAKER than x86-64’s TSO (#1143).

The one knob here that is not a size, a policy or a preference: it states what the hardware does, so that an ordering precondition can be a static_assert instead of a paragraph. The precondition it carries today is the delivery-skip Dekker pair (#635, #1140) — vertex_t::own_subs_ordered against ADR-0049’s subscribe latch — whose seq_cst halves are argued from the code rather than from coverage, because a relaxed ablation leaves the whole suite green wherever CI happens to run. kDeliverySkipOrder (vertex.hpp) is the constant this refuses to see weakened.

Default : assume weak unless a target says otherwise. The two directions are not symmetric. Saying true on a TSO host costs exactly nothing — the orders this gates are already seq_cst on every target, so the assertion is satisfied as shipped and no instruction changes. Saying false on a target that is actually weak silently disarms the check on the one class of target it exists for, and the shipped set is mostly that class: rv32 (esp32c6/c3), Cortex-M0, and the ubuntu-24.04-arm CI leg (#1140) are all weakly ordered, and a raw -I consumer — a vendored source drop, the footprint gate — states nothing at all. The value that is safe to inherit in silence is therefore the strict one.

It never SELECTS a weaker order: nothing reads this to relax an access, so a target that sets it false gets the same instructions, only a check that stops firing. Override fragment: static constexpr bool kWeaklyOrdered = false; — worth setting only for an x86-64-only build that wants the freedom to relax those loads, which is a decision to take deliberately rather than by omission.

static constexpr bool kBusLinks = true

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 keep (the default) and what closing it buys. 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.

Who should set it. 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). Override fragment: static constexpr bool kBusLinks = false;

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.

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::uint32_t tr::graph::kPinNever = 0

The reserved default_config_t::kPinPayloadRatio sentinel: never pin, always take the ADR-0041 §2 one-copy store (RFC-0022 §3.D).

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::kHazardReaderSlots = config_t::kHazardReaderSlots

default_config_t::kHazardReaderSlots for this build.

constexpr std::uint32_t tr::graph::kPinPayloadRatio = config_t::kPinPayloadRatio

default_config_t::kPinPayloadRatio for this build.

constexpr bool tr::graph::kWeaklyOrdered = config_t::kWeaklyOrdered

default_config_t::kWeaklyOrdered 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::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.

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 sp_atomic_slot_t

The slot libtracer ships today: std::atomic<std::shared_ptr<const rope_t>>.

Reclamation is the shared_ptr refcount, so there is no scheme to implement and no registry to size — the reason this is the checked-in default, and the reason a raw -I consumer and the stock ESP-IDF component keep building exactly what they built before the slot became a policy.

Lock-free BY CONTRACT, and spin-locked in practice. std::atomic<std::shared_ptr<T>>::is_lock_free() returns 0 on libstdc++, so both load and store take its internal pointer-lock bit (lock cmpxchg to acquire, an xchg to release). Measured, that is ~77 of the ~316 cycles of an in-process write and the largest single term left on the path — 88% of store’s samples land on those three instructions. Do not read “lock-free” here as “no serializing operation”; ADR-0064 §2 records why, and ADR-0069 records what replaces it on a host.

Public Types

using value_ptr_t = std::shared_ptr<const view::rope_t>

The handle a publish takes and a read returns — owning, by the contract above.

Public Functions

inline bool store(value_ptr_t sp, std::memory_order order = std::memory_order_seq_cst)

Publish. Sequentially consistent unless the caller says otherwise — the default is what orders the publish with write_seq_ and the waiter count, which is what makes the waiterless publish (#555) unable to lose a wakeup.

Returns:

Always true. This policy allocates nothing to publish — it takes a reference it was handed — so it has no failure to report. The signature exists because the contract has one (see the file header), not because this implementation does.

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

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

inline value_ptr_t load() const

Read the published value.

A mid-read reader holds its own reference, so a concurrent publish or clear cannot free the value under it — that is the whole of this policy’s reclamation.

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 shared_ptr sp_atomic_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.

Why the default is still sp_atomic_slot_t: the gain is entirely a concurrency gain — at one thread the two slots are indistinguishable within run-to-run spread, so a single-core node buys nothing and still pays (kHazardReaderSlots + 1) * 128 bytes of registry, a deferred-reclamation lifetime rule (see retire_and_flush), and a publish that can fail. Bind this one from a host preset: -DLIBTRACER_LKV_SLOT=hazard_slot_t.

Publish can fail under memory exhaustion, which sp_atomic_slot_t cannot: an empty free list makes the first publish per participant allocate a 24-byte node. It is reported, not silent — store returns false and vertex_t::store turns that into the same nullptrBACKPRESSURE 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 std::pmr::memory_resource: the slot policy is never handed one, and a bounded target that needs every byte accounted for is another target that should keep the default.

Public Types

using value_ptr_t = std::shared_ptr<const view::rope_t>

The handle a publish takes and a read returns — owning, as the contract requires.

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_ptr_t sp, std::memory_order order = std::memory_order_seq_cst)

Publish, sequentially consistent unless the caller says otherwise.

An empty handle is not a publish — use clear.

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)

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_ptr_t load() const

Read the published value.

Announce, re-read, then copy the shared_ptr out of the pinned node — the copy 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.

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 the reader itself) borrow the decoded TLV’s storage — use them while the tlv_t is alive.

Public Functions

inline explicit config_reader_t(const tlv_t *config) noexcept

Construct over config's children.

Parameters:

config – The decoded pair-container TLV; nullptr = no config (every accessor returns nullopt / nullptr).

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 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 const tlv_t *settings(std::string_view key) const noexcept

The nested SETTINGS value child of key, or nullptr.

A module namespace (“config” in the creation SPEC, a per-transport block in a connection config). Borrowed from the decoded TLV, 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).