graph — vertices, read/write/await, dispatch (L4)

In one paragraph

The graph is the node. A vertex_t is a named, addressable slot holding a value (a rope_t — a contiguous scalar is the single-link case), a bounded history, or a user handler. The data surface is read / write / await over the value, plus assign / propagate when the state transition and the edge transition are wanted separately; every control surface (subscriptions, QoS) is a field-write to a :-addressed field. write fans out to subscribers by cloning the value (a refcount bump, no copy). The last-known-value path takes no per-vertex mutex.

What it does

graph_t owns the vertex map (keyed on canonical path bytes). Each vertex has a role: stored-value (last-writer-wins), stream (the CONSUMER’s bounded ring — a producer never queues, so the ring lives on the receiving vertex and is bounded in BYTES by that vertex’s own injected mem::block_source_t via set_ring_source (RFC-0025 §4.6.1) — whose depth the owner declares host-side with set_history_depth, and which no peer can read or write — RFC-0022 §3.C), or handler (on_read / on_write — covering computed, proxy, sink, live-MMIO patterns). The last-known-value slot is an atomic<shared_ptr<const rope_t>> swap, so read / write of the value take no per-vertex mutex; that mutex guards the subscriber list, the history ring and the await waiter accounting, and a per-vertex condvar makes await block until the next write.

The slot is not free of serializing instructions. std::atomic<std::shared_ptr<T>> is not lock-free on libstdc++, so both load and store take its internal pointer-lock bit — “lock-free by contract, spin-locked in practice” (sp_atomic_slot_t, core/include/libtracer/lkv_slot.hpp:100-105). The claim the code supports is the mutex one, not an absence of contention; the cost of that spin and the policy that replaces it on a host are in design/concurrency.

Subscriptions are field-writes, not a verb (ADR-0006 — read/write/await API, no connect): subscribing is writing a SUBSCRIBER TLV into :subscribers[]. On each write the dispatcher clones the value to every subscriber’s target vertex and in-process callback. A delivery terminates at its target — store and notify, never a re-dispatch to the target’s own :subscribers[] — so a dispatch-level cycle cannot form and there is no depth cap to tune (core/include/libtracer/graph.hpp:87-92; ADR-0051 — delivery terminates at target, no dispatch limits, RFC-0007 — delivery terminates at target). Propagation past a target is exclusively the target’s own logic — a controller re-emitting on its execution. :schema reads return a POINT descriptor.

Interface

enum class role_t { STORED_VALUE, STREAM, HANDLER };
enum class delivery_mode_t { IF_NEWER, UNCONDITIONAL, EXPLICIT };

// There is NO per-vertex settings type. RFC-0022 §3.B deleted `settings_t` outright: four
// of its seven knobs were inert, `durability` became the subscription's (below), and the two
// survivors are construction parameters an OWNER declares — see set_history_depth /
// set_pin_payload_ratio. Nothing is inherited (§3.F).

struct delivery_policy_t {  // ONE subscription's delivery policy (RFC-0022 §3.A) — 2 B packed
    std::uint16_t bits;     // 0-1 reliability | 2-4 priority | 5 durability_request | 6-15 rsvd
};

struct write_ctx_t {         // the per-call context a write carries into on_write (#375)
    std::string_view subject;  // the writer's resolved subject token; EMPTY = the local host
    bool is_local_owner() const noexcept;  // subject.empty()
};                           // BORROWED for the call, like the rope beside it — copy if retained

struct handlers_t {                                       // four seams, not two
    std::function<result_t<rope_t>()>                        on_read;
    std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write;
    std::function<result_t<view_t>()>                        on_children;
    std::function<void(std::string_view, const view_t&)>     on_app_field_write;
};

using subscriber_fn_t = void (*)(void* ctx, const rope_t& value);
class  subscription_t { /* opaque: producer vertex + :subscribers[] slot index; graph_t is the
                           sole friend. Public: default-construct, copy, operator==. */ };

class graph_t {
    explicit graph_t(std::pmr::memory_resource* mr    = std::pmr::get_default_resource(),
                     mem::mem_backend_t* value_backend = &mem::heap_backend(),
                     mem::block_source_t* ctl          = &mem::heap_source());

    // registration and removal
    vertex_handle_t register_vertex(const path_t&, role_t, handlers_t = {});
    result_t<vertex_handle_t> try_register_vertex(const path_t&, role_t, handlers_t = {});
    result_t<void> retire(vertex_handle_t);                       // logical absence, subtree-wide
    std::uint32_t  retire_generation(vertex_handle_t) const noexcept;
    void           collect();                    // free the parked value seams — CALLER-timed
    std::size_t    parked_seam_count() const;    // how many await a collect()
    std::optional<vertex_handle_t> find(std::span<const std::byte> key) const;

    // the node-scoped vertex index — bound-path addressing (RFC-0024 §6.4)
    std::size_t vertex_slot_count() const noexcept;
    std::optional<vertex_slot_t>    vertex_slot(vertex_handle_t) const noexcept;   // mint side
    std::optional<vertex_handle_t>  deref_vertex_slot(std::uint32_t index,
                                                      std::uint32_t generation) const noexcept;
    std::optional<vertex_slot_t>    vertex_slot_at(std::uint32_t index) const noexcept;  // O(1)
    bool allows(vertex_handle_t, std::string_view caller, acl_right_t) const;  // the §6.2 check

    // value plane
    result_t<value_ref_t> read (vertex_handle_t, std::string_view caller = {}) const;
    result_t<void>        write(vertex_handle_t, rope_t, std::string_view caller = {});
    result_t<value_ref_t> await(vertex_handle_t, std::chrono::nanoseconds,
                                std::string_view caller = {});
    result_t<void>        assign(vertex_handle_t, rope_t, std::string_view caller = {});
    result_t<void>        propagate(vertex_handle_t);
    void                  set_delivery_mode(vertex_handle_t, delivery_mode_t);
    result_t<std::vector<rope_t>> history(vertex_handle_t) const;   // stream window (RETAINED)
    // RFC-0008 §E drain cursor — what the stream OWES, and how to say it is paid
    result_t<std::size_t> drain_unflushed(vertex_handle_t,
                                          std::vector<std::shared_ptr<const rope_t>>& out,
                                          std::uint64_t* gap_before = nullptr);
    result_t<void>        mark_flushed(vertex_handle_t);

    // owner-side storage declarations (RFC-0022 §3.C) — host API only, NO wire surface
    void          set_history_depth     (vertex_handle_t, std::uint32_t keep);
    // the RECEIVER's byte bound (RFC-0025 §4.6.1) — admission reservations, not placement
    void          set_ring_source       (vertex_handle_t, mem::block_source_t*,
                                         bool reliable = false);
    result_t<std::size_t>   ring_reserved_bytes(vertex_handle_t) const;
    result_t<std::uint64_t> stream_gaps        (vertex_handle_t) const;
    void          set_pin_payload_ratio (vertex_handle_t, std::uint32_t k);
    std::uint32_t pin_payload_ratio     (vertex_handle_t) const noexcept;

    // composed reads — they build a value, so they return one
    result_t<rope_t> read_children_folded(vertex_handle_t) const;
    result_t<rope_t> read_children_materialized(vertex_handle_t) const;
    result_t<rope_t> read_subtree_folded(vertex_handle_t, ...) const;

    // field plane (`:`-addressed)
    result_t<rope_t> read (vertex_handle_t, const field_path_t&, ...) const;
    result_t<void>   write(vertex_handle_t, const field_path_t&, rope_t,
                           std::string_view caller = {});
    result_t<value_ref_t> read (const path_t&) const;               // field tail → :schema, …
    result_t<void>        write(const path_t&, rope_t);             // → :subscribers[], :settings.*
    result_t<value_ref_t> await(const path_t&, std::chrono::nanoseconds);

    // subscriptions
    result_t<void>           subscribe(const path_t& src, const path_t& target,
                                       delivery_policy_t policy = {});
    result_t<subscription_t> subscribe(const path_t& src, subscriber_fn_t fn, void* ctx,
                                       delivery_policy_t policy = {});
    template <typename F>
    result_t<subscription_t> subscribe(const path_t& src, F& callback);   // lvalue only
    result_t<void>           unsubscribe(const subscription_t&);
    result_t<void>           unsubscribe(const subscription_t&, subscriber_release_fn_t);
    static std::uint64_t     deferred_release_drops() noexcept;
};

There is no std::function subscribe overload and no result_t<void> callback form. The per-edge sink is a {fn, ctx} pair so the per-publish edge snapshot under the fan-out lock is a trivial copy rather than a std::function clone that heap-allocates once captures exceed the small-buffer size (ADR-0047 — build-time closed module sets, compile-time seams). The templated overload binds callback by address, so it takes an lvalue only — a temporary lambda does not compile.

```{admonition} ctx lives until the reclamation policy’s grace point — and the library tells you when :class: important unsubscribe deactivates the slot (core/include/libtracer/graph.hpp:1697); a delivery already in flight snapshotted the edge and completes, and the {fn, ctx} pair is the one leg of that snapshot the library owns no copy of. So “when may I free ctx?” is answered by this build’s reclamation policy (ADR-0080, reference/17), not by a rule you have to keep in your head — and never by asking you to track in-flight state.

Under the default reclaim_local, pass a release hook and be told:

void on_dead(void* ctx) { delete static_cast<my_sink_t*>(ctx); }
(void)g.unsubscribe(sub, &on_dead);

The hook runs exactly once, on your thread, outside every graph lock: inline, before unsubscribe returns when you called it from outside a delivery (the ordinary case), or before the enclosing write() returns when you called it from inside one. The one-argument overload retires the edge identically and simply carries no signal — which is sufficient whenever you unsubscribe from outside a callback, since that call is already quiescent on return (core/include/libtracer/graph.hpp:1655 states the bound on ctx).


```{admonition} No strings on the hot path
:class: important
The hot path is **handle-typed** (the spec's rule,
[reference/03](../reference/03-addressing.md) §static path handles).
A `path_t` encodes the canonical PATH bytes **once** — the `path_t(std::string_view)`
constructor for a known-good literal
([ADR-0054 — path_t parse-once constructor](https://github.com/avatarsd-llc/libtracer/blob/main/docs/adr/0054-path-t-parse-once-constructor.md)),
or the fallible `path_t::parse` for a runtime string; `register_vertex` / `find` resolve a
**`vertex_handle_t`** once; then `write(v, value)` and `write(v, fieldpath, value)` reuse
those handles — **no string crafting, no parse, no map lookup per call**. The
string/`path_t` overloads are init-time conveniences.

Injected memory — no allocator baked in

graph_t’s constructor takes three memory seams, all defaulted to the standard heap (a host that passes nothing gets zero-churn, byte-identical behavior):

The parameters are appended in that order, so an existing graph_t{&mr} keeps compiling and picks up the defaults.

A bounded target points all three — and the transport-receive backend — at one static slab; pool exhaustion surfaces as BACKPRESSURE, never a silent heap fallback. See reference/09 §the injection points.

// idiomatic: encode the path once (parse-once ctor), reuse the handle
path_t p("/x:settings.app.setpoint");                    // once — no *-deref
auto v = *g.find(p.key());                               // once — find → optional<vertex_handle_t>
for (...) g.write(v, p.field(), setpoint_tlv);           // hot loop — zero strings

What a read hands back

read and await return result_t<value_ref_t>, not result_t<rope_t> (core/include/libtracer/graph.hpp:1270,1476 by handle, :2073,2079 by path; value_ref_t at core/include/libtracer/vertex.hpp:241). A value_ref_t is an owning reference to the value the vertex published: the LKV slot holds it as a std::shared_ptr<const rope_t>, so handing that reference back costs a refcount clone of one control block instead of one segment_ptr_t clone per link.

The rule, and the reason the API is not uniform:

A read of a published value returns a reference to it; a read that composes a new value returns the value.

read_children_folded, read_children_materialized and read_subtree_folded compose a tree no vertex ever published, so there is no object to reference and they still return result_t<rope_t>. The field-read overload likewise serves a control TLV as a rope_t. The rejected alternative was a uniform rope_t return: it makes every read of a shared vertex pay a contended refcount read-modify-write per link, on a cache line every reader of that vertex shares, so its cost grows with links and with readers.

Spelling a read of a single-link value:

auto got = g.read(v);                       // result_t<value_ref_t>
if (!got) return got.error();
std::span<const std::byte> b = (*got)->only().bytes();   // (*got) → const rope_t&

operator* yields the rope_t, operator-> reaches its members; only() is the single-link accessor (zero copy) and materialize() the general one. (*got)->only() is the correct spelling — one dereference for the result_t, one for the reference.

A held reference pins the value

Holding a value_ref_t keeps the value alive, exactly as the reader’s own copy did. Under an injected std::pmr::memory_resource that is a real obligation rather than a formality: the value was allocated from the graph’s resource, so an outstanding reference pins that allocation and defers its reclamation (ADR-0069 — LKV slot is a compile-time policy, hazard reclamation). A reader that parks a value_ref_t in long-lived state holds a bounded pool’s block for that long.

Assign and propagate

write is not irreducible. It is assign — the state transition — followed by delivery — the edge transition (RFC-0008 — vertex operations, assign and propagate §D). Splitting them is what makes “update many fields, notify once” expressible without a notion of a batch:

Call

State

Edges

assign(v, value)

swaps the LKV, appends to the stream ring, bumps the write sequence (waking await), marks v for the next covering sweep

none

propagate(v)

none

delivers v’s current value, plus the qualifying descendants of v’s subtree

write(v, value)

as assign

delivers immediately

assign is WRITE-gated like write and is never gated by delivery_mode. A branch POINT decomposes and assigns each descendant, notifying nothing. propagate takes no value — it reads the last-known-value — and always delivers the vertex named, because that vertex is the explicit target; the mode gates only what an ancestor’s sweep sweeps up. Its cost is O((pending + unconditional) in subtree).

Both verbs require RETENTION (RFC-0008 Amendment 2). A HANDLER vertex retains nothing — it hands the value to on_write and stores no LKV — so the pair had nothing to carry between the two calls and the sweep delivered silence. assign(v, …) and propagate(v) therefore answer SCHEMA_NOT_FOUND when v’s role retains nothing (the contract-mismatch status, not BACKPRESSURE); use write, which dispatches the seam and delivers in one step. Only the sweep root is judged. The same amendment makes await serve its woken value through the same role dispatch read uses, so a handler vertex answers an await with its on_read-composed value instead of NOT_FOUND.

set_delivery_mode(v, mode) sets that per-vertex policy. It is a wiring-time host API call, in the same family as set_history_depth, set_pin_payload_ratio and set_app_fields — an owner declaration with no wire surface.

delivery_mode_t

An ancestor’s sweep includes this vertex

IF_NEWER (default)

only if it was assigned since the last covering sweep — the structural coalescing flush

UNCONDITIONAL

always, at the sweep’s rate — a sweep-driven keepalive

EXPLICIT

never; deliverable only by a direct propagate on the vertex itself

The mode is a structural filter, not a value filter: nothing here compares bytes, and a vertex never parses its own bytes. Numeric filtering (a deadband) is an application filter vertex, never a field here. The protocol half of this model — what a peer sees, and how coalescing composes across a link — is reference/02.

Write and fan-out

        sequenceDiagram
    participant P as publisher
    participant G as graph
    participant V as /sensor/temp
    participant S1 as subscriber (callback)
    participant S2 as subscriber (target vertex)
    P->>G: write(/sensor/temp, rope_t)
    G->>V: atomic LKV store (no per-vertex mutex)
    G->>V: snapshot subscribers (brief lock)
    G-->>S1: fn(ctx, clone)  %% refcount bump
    G-->>S2: store + notify at the target  %% no re-dispatch from there
    Note over V: await waiters woken via condvar
    G-->>P: OK
    

The subscriber snapshot is taken under the per-vertex mutex and the sinks are called outside it, so a callback may re-enter the graph. Because a delivery landing on a target does not re-fan from that target, re-entry cannot build a dispatch cycle.

A remote subscriber’s delivery does not go on the wire from here: the fan-out hands {link, return_route, delivery_compact} and the value to the graph’s injected remote-delivery sink, which is a tr::net concern. See fwd-router and transport.

Status codes

status_t (core/include/libtracer/status.hpp:25-44) is the error side of every result_t. When the operation arrived over the wire, the FWD resolver maps it to the registered tr:: error code the kind=ERROR reply carries (error_code(status_t), core/src/fwd_reply.cpp:33-77 — a private TU under src/, not part of the public API).

The table below is a total map, and the compiler keeps it that way: error_code is a switch with no default: label and no fall-through tail, compiled under -Werror=switch, so a status_t gained without a row here is a red build rather than a status that goes out under some other member’s wire code. It reads as a formality only until you notice that the two enums are deliberately separate registries — status_t is L4 vocabulary, err_t is the wire’s — which is what makes the mapping hand-written and therefore losable.

status_t

Wire error

What produces it

NOT_FOUND

PATH_NOT_FOUND

the path resolves to no live vertex (never registered, or retired), or the vertex holds no last-known-value yet

PERMISSION_DENIED

ACCESS_DENIED

a subject resolver is installed and the target’s effective ACL grants the operation’s right to no matching, non-expired ACE

INVALID_PATH

PATH_INVALID

path_t::parse on a malformed path or a non-UTF-8 NAME segment

TYPE_MISMATCH

SCHEMA_TYPE_MISMATCH

a payload whose type the vertex or field cannot take; also set_identity with a kind outside the registry or a key length contradicting the kind

BACKPRESSURE

FLOW_BACKPRESSURE

an allocation a peer can provoke could not be served from the injected nothrow control seam, or a per-subscriber queue cap is exceeded

TIMEOUT

FLOW_TIMEOUT

an await deadline expired

SCHEMA_NOT_FOUND

SCHEMA_NOT_FOUND

a field read or write on a vertex that exposes no such field — an undeclared app field, :identity on a node with no key installed, a :children[] SPEC whose type is unregistered

PATH_IN_USE

PATH_IN_USE

try_register_vertex collided with a live vertex at that address

TRANSPORT_DOWN

TRANSPORT_DOWN

a transport-construction failure: a dial refused, a TLS/WebTransport handshake rejected, a listener that could not bind, a CAN interface the kernel would not open

BACKPRESSURE is the allocation-failure and flow-control answer. It is not a dispatch-depth signal: no depth cap exists.

TRANSPORT_DOWN is the only member of this table whose point is the disposition, not the name. Its wire code is TRANSIENT in the registry — retry may succeed — while every other row here is PERMANENT or (for BACKPRESSURE / TIMEOUT) transient for a reason the caller can already see. Until #929 the built-in transport factories spent NOT_FOUND on a link that did not come up, so a refused connect went out as tr::path::not_found and a peer reading the disposition off the code stopped retrying a link that would have come back. Nothing before #929 could reach err_t::TRANSPORT_DOWN from this side: the map was total over a status_t that had no member for it.

Setup-time seams

Five installers configure a graph before frames flow. Each is set once at wiring time, from one thread. That is the doctrine, and since #1049 it is stated by the API rather than requested in a comment: the three callback seams are spelled configure_* and take the ADR-0047 {fn, ctx} pair, never a std::function.

The shape is the enforcement. A std::function cannot be handed to a racing reader at all — assigning one destroys the old target, freeing its captures while a reader may be inside the call — whereas a bare function pointer is one word, so the pair publishes through a sink_slot_t exactly as the router’s five sinks do. A fan-out, an ACL gate or a subscribe that races an install therefore sees the whole new pair, the whole old one, or no sink for that one operation; it never sees a new fn beside a stale ctx, and never a freed capture. The read costs what the null check it replaces cost: one relaxed load when nothing is installed.

The two remaining seams are not callbacks and the slot does not reach them, so they take a lock instead — which is free, because both are control-plane cold. The child-type catalog is a std::map whose lookup runs from a peer’s bytes; the identity record is a buffer whose read is served above the READ gate, to a peer that has authenticated nothing (RFC-0011 §C), and which install and clear both free. Neither lock touches a read, write or dispatch path.

The ctx pointer is the caller’s, and must outlive every operation that can still reach the seam: clearing a sink does not stop a dispatch already in flight.

Seam

Effect

Default

register_child_type(type, factory)

populates the in-band creation catalog: which type selector a :children[] SPEC write may instantiate

only the built-in stored_value; an unregistered type answers SCHEMA_NOT_FOUND

set_identity(kind, key) / clear_identity()

installs the node-scoped record read <vertex>:identity serves, byte-identical from every vertex

absent — :identity answers SCHEMA_NOT_FOUND

configure_remote_delivery_sink(fn, ctx)

where the producer fan-out hands each remote subscriber’s delivery

null — remote subscriber slots are stored but never deliver

configure_subject_resolver(fn, ctx)

maps a caller context to a subject token, enabling ACL evaluation

none — enforcement is entirely off; every operation is allowed

subscribe_wire(v, source, route, link, reverse, caller)

the inbound :subscribers[] append: one parse, the SUBSCRIBE gate, the slot append, the durability latch the subscriber requested. link is WHERE the edge delivers; caller is WHO subscribed (empty ⇒ the same as link, every pre-#375-Part-2 caller)

— (called by the FWD resolver, not a default)

The two defaults in bold are load-bearing and are the two failure modes a node wired by hand hits first. A graph with no remote-delivery sink accepts remote subscribes and records them; nothing ever leaves. A graph with no subject resolver is fully open, whatever :acl bytes its vertices carry.

set_identity involves no cryptography. The record is a claim: the seam stores and serves the bytes the owner supplies and verifies nothing. Proving a node holds the key is authentication and lives elsewhere; a claim is nevertheless what a trust-on-first-use peer pins and what a topology walk deduplicates by. :identity resolves above the READ gate, so an unauthenticated peer can fetch it — a narrow, named exemption for that one field (RFC-0011 — node identity facet).

Delivery drops

A delivery can be lost after the write succeeded. delivery_drops() returns the only record of it:

struct delivery_drops_t {
    std::uint64_t no_target;          // no live vertex: an edge's target PATH, or a net-plane route
    std::uint64_t denied;             // a WRITE was refused by the target's :acl — on any plane
    std::uint64_t out_of_memory;      // a nothrow delivery clone / edge-view copy could not allocate
    std::uint64_t fan_out_truncated;  // a wide fan-out's snapshot could not be widened past the
                                      // inline prefix — the capacity degrade, kept apart from OOM
};

The unit is a delivery, not an event: an assign whose pending mark cannot be allocated sheds every subscriber of the vertex, and a truncated snapshot sheds every edge past the inline prefix, so each counts once per shed delivery (1 never stands in for N). A HANDLER write whose notify clone failed used to be the widest case of this; #1505 removed the clone, so that shed is now impossible rather than merely counted.

denied counts a refusal on every plane the value-write path is entered from — an API write, a FWD{WRITE} terminus, a COMPACT terminus, and a subscription edge’s fan-in gate — because it is counted at the graph’s own WRITE gate rather than once per deliverer (#1068). It is therefore refusals, not refusals nobody was told about: an API caller both receives PERMISSION_DENIED and counts here. A number that depended on which door a refusal came through could not be summed. assign, a control-plane field write and a denied READ are each a different right or a different path, and are deliberately not folded in.

A deliverer outside the graph — the net plane resolving a label to a vertex and writing it — counts its own abandoned deliveries through count_external_drop, the one public door to these counters. It names only NO_TARGET and OUT_OF_MEMORY: a denial is counted at the gate that produces it, so offering it there would count one refusal twice.

Counted, never enforced: nothing in the library reads them, so a deployment chooses whether to alarm. They are relaxed monotonic and incremented only on a drop, so the delivering path pays nothing when nothing is dropped. The loads are individually relaxed rather than one atomic snapshot — making them coherent would put a lock on the delivery path to serve a diagnostic, and the useful reading of a monotonic counter is “is this growing”, not an instant.

A subscriber whose target was retired, or whose caller lost the WRITE right, silently stops receiving. There is no other instrument for that.

Declaring owner fields

Application properties live under :settings.app. and are declared by the owner, never invented by a peer. Declaration is a local host call with no wire operation behind it: the field catalog is device state (RFC-0010 — owner app fields and schema §A.1). Every undeclared name answers SCHEMA_NOT_FOUND.

enum class app_access_t { RO, RW, WO };   // constrains REMOTE callers only

struct app_field_t {                      // owning install
    std::string           name;           // key below settings.app. ("kp", "wifi.ssid")
    app_access_t          access;
    std::vector<std::byte> descriptor;    // §B.1 record served verbatim inside :schema
    std::vector<std::byte> value;         // optional initial value
};

void set_app_fields       (vertex_handle_t, std::vector<app_field_t>);  // owning
void set_app_fields_static(vertex_handle_t, borrowed_fields_t);        // borrowed, zero-copy

set_app_fields

set_app_fields_static

Name and descriptor bytes

copied into the graph

viewed, never copied

Initial value

may carry one

declaration only; write values afterwards

Caller obligation

none

the table array and the bytes it points at outlive the vertex

borrowed_fields_t converts implicitly from the array spellings a constexpr table in flash takes, and not from a std::vector — so a caller whose storage cannot satisfy the lifetime rule fails to compile rather than dangling. A runtime-sized table opts out explicitly via borrowed_fields_t::unchecked. For an MCU owner whose table is constexpr in .rodata, the borrowed form costs zero declaration RAM (ADR-0058 — vertex_ext storage classes, borrowed declarations and group split).

access constrains remote callers only — the owner always reads and writes its own declared fields. WO gives a secret no read surface, so it never mirrors back.

The runtime validates addressing only: declared or undeclared, and writability. Range and dtype checking is the owner’s, in handlers_t::on_app_field_write, which fires after a declared field write has stored its bytes, with the field’s key and the written TLV. That seam runs outside the vertex lock, so it may re-enter the graph — apply the config, restructure children, then announce the change with an ordinary data write. An app-field write never wakes await and never propagates; a change consumers should notice is followed by the owner’s own announce write.

Pitfalls

Rule

The failure mode

subscribe(src, F& callback) binds by address

passing a temporary lambda does not compile — which is the intent; a caller that “fixes” it by storing the lambda in a shorter-lived scope than the graph reintroduces the dangle the signature was shaped to prevent

ctx is freed at the policy’s grace point, not “whenever”

freeing ctx on unsubscribe’s return is correct under both shipped policies when the call came from outside a delivery — and is a use-after-free when it came from inside one, where a live fan-out is still walking a snapshot that names the pair. Pass a subscriber_release_fn_t and let the library say which case you are in; it is the only party that can (reference/17)

Two of the three policies speak for ONE thread’s dispatch domain

reclaim_strict and reclaim_local bound the wait by this thread unwinding, so a node that dispatches from several threads at once and unsubscribes from another gets no guarantee from either — reclaim_local will free a context a sibling thread is still delivering to. Bind reclaim_qsbr_t for that node (using reclaim_policy_t = reclaim_qsbr_t;): its grace point spans every dispatching thread. The trade is that its release hook may then run on a thread other than the unsubscribe() caller, so the hook must be thread-safe with respect to its own context

A re-entrant unsubscribe needs a parking slot

reclaim_local parks at most kDeferredReleaseSlots (default 16) pairs per thread per dispatch stack. Past that a pair is dropped and its hook never runs — a leak, chosen over a use-after-free. graph_t::deferred_release_drops() is how an undersized bound shows up before it matters

read returns a reference

keeping a value_ref_t in long-lived state pins that allocation; under an injected pool, a handful of parked references is a pool that never drains

only() is the single-link accessor

calling it on a multi-link rope is not the general path; materialize() is. A value that arrived as a subview of a frame, or that was written as a rope, has more than one link

A retired handle stays dereferenceable

retire empties the vertex in place and never frees it, so a stale handle silently addresses a re-virginized slot. A holder that caches a resolution records retire_generation beside it and re-reads before use — and must not cache an authorization decision that way, since a generation match says the vertex is the same one, never that the caller may still act on it

retire parks a value seam; only collect() frees it

the seam is read lock-free, so retire cannot free it — it parks it on the graph. A vertex bears a seam iff a handler was installed (presence, not role: STORED_VALUE + on_children parks, HANDLER + empty handlers_t does not). Nothing frees the park until the embedder calls collect() at a point it knows no reader holds a seam. Connection teardown retires the /net/<module>/<name> identity vertex, which is seam-bearing only over a bus link (link->bus() != nullptr: CAN, or a tcp/ws server wired peer_named = true) — so a bus node with peer churn that never collects grows the park forever, while a point-to-point deployment parks nothing; parked_seam_count() is how that shows up before it matters (#576)

No subject resolver means no enforcement

writing :acl bytes on vertices and never installing a resolver yields a node that looks protected and is fully open

No remote-delivery sink means no remote delivery

remote subscribes are accepted and stored; the delivery_drops() counters stay at zero because nothing was dropped — nothing was attempted

Consequences

  • Two irreducible operations, not one. assign and propagate compose into write; splitting them expresses “update many, notify once” without a batch API, and keeps the coalescing policy on the vertex rather than on the edge.

  • No per-vertex mutex on the value path. The LKV is an atomic pointer swap; the mutex guards the subscriber list, history and await accounting. Race-freedom under TSan is evidence about data races, not about blocking — the slot’s serializing instructions are real and measured in design/concurrency.

  • Zero-copy fan-out. N subscribers get N refcount clones of one rope_t, not N copies.

  • No dispatch limits. Delivery terminating at the target removes the cycle, so no depth counter, no hop budget, and no synthetic constant to tune per deployment.

  • The value is the bytes. A vertex stores a rope_t, so what it holds is exactly what goes on the wire.

API reference

class graph_t

The L4 in-process graph runtime: the Composite vertex tree plus the whole data API (register / read / write / await / subscribe, ADR-0006).

Vertices form a Composite tree (ADR-0057): each node stores its own NAME segment and its children; a canonical PATH-TLV payload key (docs/reference/02 §dispatch) resolves by an O(segments) child walk at wiring frequency. The hot path resolves a vertex_t* once — at registration or via one guarded find — then read/write/await on that handle are lock-free in the vertex’s last-known-value slot. Non-copyable; a graph is a fixed runtime root.

Public Types

enum class external_drop_t : std::uint8_t

Why a deliverer OUTSIDE the graph abandoned a delivery before it could write.

Narrow on purpose (#1068). It names only the two ways a net-plane delivery dies without ever reaching write — the route resolves to no vertex, or the payload view cannot be allocated. There is deliberately no DENIED: a refusal happens AT the graph’s own WRITE gate, which counts it there, so offering it here would let one refusal be counted twice by a caller that also saw PERMISSION_DENIED.

Values:

enumerator NO_TARGET
enumerator OUT_OF_MEMORY
using child_factory_t = std::function<result_t<vertex_handle_t>(graph_t&, std::vector<std::byte> child_key, const wire::tlv_t *config)>

A child-vertex factory: the device-catalog entry ADR-0017 makes concrete.

Given the composed child key (parent key + the SPEC’s name NAME) and the optional SPEC config SETTINGS, it registers the child vertex(es) and returns the primary handle (or a status — e.g. PATH_IN_USE). The graph owns the addressing (the key is composed for it); the factory owns the catalog (what a type instantiates).

Public Functions

explicit graph_t(std::pmr::memory_resource *mr = std::pmr::get_default_resource(), mem::mem_backend_t *value_backend = &mem::heap_backend(), mem::block_source_t *ctl = &mem::heap_source(), mem::block_source_t *ring = &mem::heap_source())

Construct an empty graph (registers the built-in stored_value child type).

Construct a graph drawing its per-write control-block allocations from mr (ADR-0039 §1, #361 §5) and its write-path value byte-buffers from value_backend (ADR-0060).

mr allocates the LKV control block + rope_t wrapper object; value_backend is the L0 byte-buffer seam the write-path copy-store draws its owned value view::segment_t from — the single flatten of a branch or field write (graph.cpp sites 825, 1017). A bounded node points BOTH at one static slab (“one slab, whole stack”); a host passes nothing and gets the standard heap for each (zero churn, behaviour byte-identical).

The seam’s scope is PAYLOAD bytes, which includes READ-path framing and not only the write-path copy-store (#831): BOTH folded READs frame their exactly-sized POINT headers from it — one per subtree node in the composed-root fold, and one per registered child plus the outer listing header in the ":children" fold the wire field READ routes to. These are payload bytes whose length field wraps the stored TLV and the name records below it, as distinct from the route-byte-sized reply-egress seam of ADR-0074. Both counts are peer-influenced, so an injector sizing a bounded slab must budget for them; the size classes are the host’s composition problem (ADR-0060 §3 keeps the graph size-agnostic).

An injected value_backend MUST be thread-safe (ADR-0060 §2): a value view::segment_t self-routes its reclaim on whatever thread drops the last ref — typically a reader/subscriber, concurrent with a writer’s alloc — so sharding it per lock-stripe removes no race. The default heap_backend() already is thread-safe; a pool_t must be composed with the target’s arch-selected synchronisation. On exhaustion value_backend returns nullptr (the BACKPRESSURE signal), and the write rejects rather than silently falling back to the heap (§3). mr and value_backend must both outlive the graph and every value handle obtained from it.

Parameters:
  • ctl – The #551 nothrow seam every FAILABLE allocation draws from — the ones a PEER can provoke (“failable”, not “control-plane”: CONTEXT.md binds that phrase to the : field-write plane): vertex registration first, then the route_handle label tables, tlv_arena nodes, fwd_router iov and can_reassembly maps as each migrates. On exhaustion it returns nullptr and the operation answers BACKPRESSURE, so a peer’s CREATE frame can no longer reboot a -fno-exceptions node. Deliberately a DIFFERENT C++ type from mr so the two contracts (must-not-be-null vs may-be-null) cannot be transposed by a one-token edit, and so retiring mr later is a compile error rather than a silent rebind. Appended, not prepended, so every existing graph_t{&mr} call site compiles unchanged. Must outlive the graph, like the other two.

  • ring – The GRAPH-LEVEL DEFAULT receiver-ring source (RFC-0025 §4.6.1 clause 3): where a STREAM vertex’s ring admissions are charged when that vertex has declared no source of its own (set_ring_source). It is a DEFAULT so that every receiver has somewhere to charge — not a shared pool: composition stays per-injection-point, and per-vertex isolation is a tested property. A different seam from ctl on purpose — exhausting a node’s control budget and exhausting one plane’s queue budget are different failures with different blast radii, and the flood test asserts exactly that separation. Must outlive the graph.

inline mem::block_source_t &control_source() const noexcept

The injected #551 nothrow failable-block seam (tr::mem::block_source_t).

Exposed so a host can name it in a memory census and so the wiring is observable without reaching into the graph’s state. Callers inside the library draw from ctl_ directly.

inline mem::block_source_t &default_ring_source() const noexcept

The graph-level DEFAULT receiver-ring source (RFC-0025 §4.6.1 clause 3).

What a STREAM vertex charges its ring admissions against until it declares its own through set_ring_source. Exposed for the same reason control_source is: so a host can name it in a memory census and so the wiring is observable.

vertex_handle_t register_vertex(const path_t &path, role_t role, handlers_t handlers = {})

Register a vertex at a known-good path LITERAL, parsing nothing further (any :field tail is ignored) — INFALLIBLE (ADR-0056).

The init-time registration form: a PATH_IN_USE collision on a compile-site literal is a source bug, not a runtime condition, so this hard-aborts (like path_t(std::string_view), ADR-0054) rather than yielding a result_t the caller would only *-deref unchecked. Returns the pinned vertex_handle_t directly — no *. For a genuine runtime path whose collision is a real outcome, use try_register_vertex.

result_t<vertex_handle_t> try_register_vertex(const path_t &path, role_t role, handlers_t handlers = {})

Register a vertex at path — FALLIBLE (the runtime-path form of register_vertex).

Returns:

The pinned vertex_handle_t, or PATH_IN_USE if the path is already registered.

result_t<vertex_handle_t> register_vertex_key(std::vector<std::byte> key, role_t role, handlers_t handlers = {})

Register a vertex by its canonical PATH-payload key directly (the in-band :children[] path) — FALLIBLE.

The key is a composed parent-key + NAME(child), not parsed from a string. This is the genuine runtime path (a :children[] write can race a duplicate name), so it stays fallible.

Returns:

The pinned vertex_handle_t, or PATH_IN_USE if the key is already registered.

result_t<void> retire(vertex_handle_t vh)

Retire a vertex and its whole subtree — the owner-facing mirror of register_vertex (RFC-0009 §A.1 / §B).

Marks vh (and, per §B.3, every descendant) logically absent: invisible to find / read / :children[], reading tr::path::not_found exactly like a never-built path (§C). The allocation is NOT freed and the handle stays dereferenceable forever (ADR-0057 insert-only) — the vertex is emptied, not erased. Retirement re-virginizes each vertex (§B.6): it clears the previous owner’s :acl, value seam, stored value, history, app-field table, subscribers, owner-side storage declarations, and delivery mode, so a later write-creates revive of the same address inherits nothing of the retired owner — in particular the revived path inherits its live ancestor’s ACL policy, never the retired one’s (the §Discussion-7 ruling: an ACL does not survive churn). write_seq_ survives (monotonic per address).

Delivers nothing and wakes no await (§B.5). Idempotent (§B.4): retiring an already-retired or unregistered vertex succeeds and does nothing. The root cannot be retired. There is no wire operation that reaches here — a peer goes through the device’s own logic (§A.1 / §A.1.1), which is what calls this.

std::uint32_t retire_generation(vertex_handle_t vh) const noexcept

vh's retirement generation — the stamp a cached resolution carries (ADR-0062).

A vertex_handle_t never dangles (the vertex map is pinned and insert-only), but retire re-virginizes the object in place. A holder that caches a resolved handle — a route-handle terminus binding, say — records this alongside it and re-reads it before use: a mismatch means the path was retired (and possibly re-created for a DIFFERENT owner) since the resolution, so the cached answer must be discarded rather than delivered into whatever now occupies that path.

Lock-free; the counter is bumped under retirement’s own ordering. Callers must NOT cache an authorization decision this way — a generation match says the vertex is the same one, never that the caller may still act on it (ACL stays per-operation).

inline std::uint32_t own_subs(vertex_handle_t vh) const noexcept

This vertex’s OWN active subscriber-slot count (#635) — how many slots a delivery here would feed, for sizing and observability.

Relaxed by design: this answers “how much work would a delivery be”, the use vertex_t::own_subs is specified for. A racing subscribe is observed by the next read at worst, which is what a sizing hint needs.

Warning

This is NOT the “is anyone listening” question, on two counts, and a producer must not gate a publish on it — use has_subscribers. It omits subtree subscribers, who subscribe on a strict ANCESTOR and are counted by listeners_above rather than here (RFC-0005), so a zero here says nothing about them. And it is the relaxed load, which vertex_t::own_subs_ordered documents as unfit for a skip decision.

inline bool has_subscribers(vertex_handle_t vh) const noexcept

Would a delivery at vh reach any subscriber — its own, OR a subtree subscriber on a strict ancestor (RFC-0005)?

The gate for a demand-driven producer that wants to skip delivery work. It joins the two gates deliver_vertex, the per-vertex delivery unit, applies — fan_out’s own self-gate on the own count, then the listeners_above gate deliver_vertex holds over bubble_up — so a producer that skips a deliver_vertex on false skips exactly what that call would have found no receiver for. (A decomposing BRANCH write is not one deliver_vertex: it fans out at each descendant landing site under that site’s own gate, which this predicate does not answer for.) Gating on the own-slot count alone silently drops every subtree subscriber, which is why own_subs carries a warning against it. (mark_pending, the deferred half, gates on delivery_mode first and so asks a third question this predicate deliberately does not.)

Note

Swapping the seq_cst own half for the relaxed vertex_t::own_subs leaves the whole suite green, so its presence here rests on that argument, not coverage.

Warning

Subscribers are not the only consumers. read pollers and threads blocked in await are invisible here — this counts subscription edges only, which ADR-0006 makes a field-write to :subscribers[] rather than one of its three verbs. A producer that skips its delivery on false is fine; one that also skips the VALUE STORE starves every awaiter (no write_seq_ bump to wake them) and freezes the LKV for every reader.

Warning

A skip here has no durability-latch backstop. ADR-0049’s latch belongs to vertex_t::own_subs_ordered’s fan-out skip, whose protocol is store the LKV, THEN load the count; a producer that skips on this predicate never reaches the store, so there is no new value to latch. What ordering this predicate does give is just its two loads’ — the seq_cst vertex_t::own_subs_ordered and the relaxed listeners_above — making it exactly as ordered as deliver_vertex’s own two gates and no more. The seq_cst half’s argument is documented there; it is not restated here.

Warning

The ancestor half can be one subscribe behind, with no bound but the platform’s. listeners_above is a relaxed load, so a false here may miss a subtree subscribe that has already COMPLETED on another thread — latch taken, counter bumped — and nothing synchronizes when this reader catches up. That staleness is deliberate and ruled on measurement (#854, REFUTED — the seq_cst candidate doubled the idle write’s fence count on rv32 and bought nothing): per the #555 standard, the outcome a stale-false skip produces — the racing publish reaching no subtree subscriber — is indistinguishable from the write linearizing BEFORE the subscribe, and the subscriber’s ADR-0049 latch cannot contradict that ordering, because the latch snapshots the SUBSCRIBED ancestor’s own LKV (vertex_t::add_edge), which never holds a descendant’s value. There is no forbidden observation for an ordered load to exclude, so the ordered load does not exist.

std::size_t vertex_slot_count() const noexcept

Slots in the node-scoped vertex index — the cardinality a bound-path element’s index is bounds-checked against (RFC-0024 §6.4).

One slot per vertex_t ever allocated in this graph, in allocation order, slot 0 being the structural root. The index is append-only because registration already is (“vertices are added, never erased”), so a slot handed out once names the same allocation for the graph’s lifetime and there is no new invalidation event to observe.

Node-local and unobservable on the wire: a peer learns another node’s cardinality only by being handed an element that came from it, and an element is meaningless anywhere but on the host that minted it.

void set_vertex_ceiling(std::size_t max_vertices) noexcept

Cap the node’s vertex population at max_vertices allocations, charged against the vertex_slot_count census (#1314).

The census already counts every vertex_t this graph ever allocated — including the placeholders a descent materializes and the landing sites an RFC-0005 §D branch write decomposes into. What it did not do was charge anything: every creation door (registration, the write-create mkdir -p, branch-write decomposition) allocated until the allocator itself refused. A branch writer that is already resolved and already WRITE-gated is therefore governed — every landing site passes its CREATE/WRITE gate — but its landing sites cost nothing, so “more writes, wider writes” is an unbounded vertex population multiplied by a peer’s choice. That is the peer/writer-multiplied allocation class ADR-0079 fences elsewhere: a per-call bound a caller can multiply is not a node bound.

This is the node bound, and it is the #838 shape — count, then act — over the census that already exists rather than a second, bespoke counter. Past the ceiling every creation door answers BACKPRESSURE, the same exhaustion status an injected tr::mem::block_source_t answers with, so a caller that already handles a refusing store needs no new vocabulary. Refusals are counted (vertex_ceiling_refusals) so a node can see the bound bite instead of inferring it from a failed write.

Policy stays with the deployer, per ADR-0079 §Decision 4: the default is kNoVertexCeiling, so an un-sized node behaves exactly as before and the library fixes no synthetic limit. When ADR-0079’s stage-2 graph placement store lands and vertex_t itself draws from the injected ctl seam, the store’s size becomes the natural bound and this ceiling becomes the coarse-grained backstop rather than the primary one.

Note

The census is append-only (retirement revives in place, it does not free), so the ceiling is a high-water mark on ALLOCATIONS, not a live occupancy that a retire gives back. That matches what it is bounding — memory a peer made this node commit — and it is why no release path is needed.

Note

A refusal mid-descent leaves the levels already created in place, exactly like an ACL denial partway down a write-create chain (“created-but-empty intermediates may

persist past a later denial”, RFC-0005 §ACL). The bound holds regardless: those levels are themselves charged.

Note

Session identity anchors are NOT charged here. They take a census slot but are created through register_session_anchor, which is already bounded by the listener’s max_peers accept policy; charging them twice would let graph growth refuse a session admission the accept policy had already granted.

std::size_t vertex_ceiling() const noexcept

The ceiling in force (kNoVertexCeiling when unset).

std::uint64_t vertex_ceiling_refusals() const noexcept

How many creations the ceiling has refused since construction (monotonic).

result_t<vertex_handle_t> register_session_anchor(std::string_view id)

Register — or REVIVE — a session identity anchor: a vertex that exists to be REFERENCED and never to be ADDRESSED (#1223 step 2).

ADR-0044’s 2026-08-13 amendment scopes §Decision 1 to announce-census peers and lets an accepted ws/tcp session hold a vertex, so that the session’s death is a RETIRE and a route naming it fails the RFC-0024 §5.1 generation check. This is the seam that gives it one. id is the session’s node-scoped identity string — the router composes it from the mount’s qualified name and the peer’s slot name, so the SAME slot always asks for the SAME anchor.

An anchor is not part of the addressable tree, deliberately. It hangs off a private structural root that root_ cannot reach, so:

  • find, read, every path descent and every :children[] listing are byte-for-byte unchanged — an anchor is invisible to all of them. That is what keeps bus_link_t::enumerate_peers the ONE source of truth for a bus vertex’s synthesized members (ADR-0044 §Decision 1, unamended in this respect), instead of a second one.

  • nothing below a bus mount becomes locally resolvable, so RFC-0020 §3’s MUST (“a node

    MUST NOT resolve the residual against its local graph”) keeps the premise it was argued on. An anchor cannot be the shadow vertex that MUST is about, because no spelling of any

    dst reaches it. What an anchor DOES have is the only thing it is for: a slot in the pinned, insert-only vertex map, hence a (index, generation) an RFC-0024 element can name.

Revive is in place. The anchor for a given id is allocated ONCE and re-filled afterwards, exactly as a retired addressable vertex is revived by a second registration at its path — so a recycled p<slot> returns the SAME vertex_t in the SAME slot with only the saturating retire generation bumped (RFC-0024 §4.4 rule 3). Anchor count is therefore bounded by the listener’s max_peers, not by session churn, which is the measurement the ADR amendment rests on.

Retire an anchor through the ordinary retire — it is an ordinary vertex in every respect the mint, the deref and retirement care about.

Return values:

status_t::PATH_IN_USEid already names a LIVE anchor (a duplicate arrival notification, or an id collision). The caller keeps the existing anchor.

std::optional<vertex_handle_t> find_session_anchor(std::string_view id) const

The live anchor for id, or std::nullopt when none is registered (it was never created, or it has been retired). Never descends the addressable tree.

std::size_t session_anchor_slots() const noexcept

How many anchor vertex_ts this graph has ever ALLOCATED — live or retired.

The bounded-across-churn number, exposed so a test can assert it rather than infer it: it counts allocations, not registrations, so a revive must leave it unchanged.

std::optional<vertex_slot_t> vertex_slot(vertex_handle_t vh) const noexcept

This node’s own reference to vh — the MINT side of a bound-path element (RFC-0024 §6.4, §7).

Returns the index and the generation that stamps it, because the two are one fact: read separately they can straddle a retire, and the pair would then name the successor tenant’s vertex while the caller believes it bound the one its operation reached. Both fields are read under a single map_mutex_ hold, which retirement takes uniquely, so the pair is always a consistent snapshot.

Warning

This is a control-plane call and is priced as one: it finds vh by scanning the slot index, because the reverse direction is deliberately not memoized. A per-vertex index field costs 4 bytes on rv32, where sizeof(vertex_t) sits at its ceiling with zero headroom (config_t::kMaxVertexBytes32), and a pointer→index side map costs strictly more than the 4 B/vertex RFC-0024 §6.4 priced. A mint happens once per binding, on a reply already being assembled; the hot path — deref_vertex_slot — pays a bounds check and one compare and never comes here.

Return values:

std::nulloptvh's generation has SATURATED (kGenerationSaturated), so the vertex is permanently unbindable and the caller stays on the canonical form (RFC-0024 §4.4 rule 3) — or, defensively, vh is not in this graph’s index at all.

std::optional<vertex_handle_t> deref_vertex_slot(std::uint32_t index, std::uint32_t generation) const noexcept

Dereference a bound-path element — the §5.1 check, and the whole of it.

Bounds-checks index against vertex_slot_count, refuses a SATURATED generation outright, and compares the rest against the slot’s retire_generation. The vertex map is pinned, pointer-stable and insert-only, so an in-range index always names a live allocation and the deref itself cannot fault.

A generation only ever moves forward, so a stale element can only ever compare lower and never becomes valid again by waiting — except at the ceiling, where the counter stops. There, and only there, “moves forward” stops being a guard: a kGenerationSaturated element would match the slot for the rest of the node’s life, across every subsequent retire and revive, so staleness detection would be dead for that slot and the #603 misroute class the saturation rule exists to close would be open again. The mint refuses to issue such an element; this refuses to honour one, which is what makes “permanently unbindable” (RFC-0024 §4.4 rule 3) a property of the vertex rather than of one code path’s good manners.

Warning

A match authorizes nothing. It says the vertex is the same one, never that the caller may still act on it: every bound-form operation re-evaluates acl_allows at the dereferenced vertex for its own right, exactly as the canonical form does (RFC-0024 §6.2). The graph’s own data ops do that themselves, which is why the two spellings are equivalent by construction.

Return values:

std::nullopt – Out of range, saturated, or the generation does not match. The caller MUST then drop — never forward, never apply, never repair (RFC-0024 §5.3).

std::optional<vertex_slot_t> vertex_slot_at(std::uint32_t index) const noexcept

The element a mint would issue for the slot at index — the FORWARDER’s mint (RFC-0024 §7.1 step 2), in O(1).

The terminus mints for a vertex it just resolved, so it has a handle and can afford vertex_slot’s scan. A forwarder mints for the connection vertex of the link a reply arrived on — a vertex whose index it recorded once, at registration — so all it needs is that index’s CURRENT generation, and paying a scan of the whole index per forwarded reply to re-derive an index it already holds would be the wrong shape at the wrong place. This is the same read the other way round: index in, generation out.

Return values:

std::nulloptindex is out of range, the slot’s generation has SATURATED — a permanently unbindable vertex (RFC-0024 §4.4 rule 3) — or the slot holds a retired/never-registered PLACEHOLDER, which deref_vertex_slot refuses on the honouring side and which is therefore refused here too: otherwise the window between a retire and its revival mints an element valid against the SUCCESSOR tenancy. A forwarder that cannot mint STRIPS the mint answer (§7.1 erratum 1) and the origin stays canonical.

bool allows(vertex_handle_t v, std::string_view caller, acl_right_t right) const

Evaluate the ACL at v for caller and right — the §6.2 check, exposed.

The same predicate every data op already runs before it acts, published for the ONE caller that reaches a vertex without performing a data op on it: the bound-path forwarder, whose element dereferences to a connection

vertex it will egress through rather than read or write (RFC-0024 §6.2 — “every operation arriving on a bound

path MUST evaluate <tt>acl_allows</tt> at the dereferenced vertex, for the operation’s own

right”). Nothing is cached: an

:acl write marks the subtree dirty and the next call rebuilds, so a revoked right takes effect on the very next frame over an already-minted binding.

Parameters:
  • v – The vertex to evaluate at.

  • caller – The subject context — a transport link name; empty is the trusted local caller, which is allowed everything (the shipped convention).

  • right – The right the operation needs.

void collect()

Free every value seam retire parked — the EXPLICIT collector (#576).

retire detaches a vertex’s value seam and parks it: the seam is read lock-free, so the retiring thread cannot free the block a concurrent reader may still be dereferencing. Parking alone has no other end, so a node that retires seam-bearing vertices repeatedly grows the park forever. This is that other end, and it is the embedder’s call, not the library’s.

Which vertices park — handler PRESENCE, never role. vertex_t::adopt_identity allocates the value_handlers_t iff at least one of on_read, on_write, on_children was installed at registration; role_t is never consulted. So a role_t::STORED_VALUE vertex registered with an on_children parks one seam on retirement, and a role_t::HANDLER registered with an empty handlers_t parks nothing. Scoping a quiescent point by role excludes exactly the production case below.

The one peer-driven append site is conditional. tr::net::transport_vertex_t::remove_connection retires the /net/<module>/<name> identity vertex, which is registered role_t::STORED_VALUE — and it bears a seam only when its link exposes a bus facet (transport_t::bus() != nullptr): the CAN binding, and a tcp/ws server wired peer_named = true, get an on_children that synthesizes the live peer listing (ADR-0044). A point-to-point deployment — every dial link, UDP, loopback, a default-wired server — parks nothing on teardown and needs no quiescent point at all. A bus node parks one value_handlers_t (~96 B of std::function, plus each callback’s captures) per teardown; that node is the one this method exists for.

The free runs on the CALLER’s thread and OUTSIDE every graph lock: the parked list is swapped into a local under the map lock, and the local destructs after the lock is released. So a seam callback’s destructor may re-enter the graph (drop a handle, find a path, retire something else) without deadlocking, and an arbitrarily slow destructor blocks no reader or writer.

Idempotent, and a no-op when nothing is parked. Not itself a reader-safety mechanism: it neither waits for nor detects readers. An embedder that never calls it keeps the pre-#576 behaviour — the park grows without bound — which parked_seam_count makes observable.

Warning

The caller MUST call this from a point where no lock-free reader holds a value seam. The library cannot know that moment — a reader holds the raw seam pointer across the user callback it invokes — so naming it is an API obligation this method hands to the embedder. On a single-threaded node any point between operations qualifies. On a threaded node, a point where the graph is quiescent for reads does: after the transport plane’s receive threads are joined or paused, or on the one thread that runs every graph operation. The hazard is NOT limited to a thread that started on an already-retired vertex: read / write / :children[] load the seam pointer ONCE (deliberately — a second load could see a concurrent retire’s null), so a thread that entered while the vertex was still LIVE holds that raw pointer across the whole user callback, and a retire landing mid-callback moves the block it is using into the park. Collecting while any such call is in flight — retired first or not — is a use-after-free.

Note

Whatever is still parked when the graph is destroyed is freed by the graph’s own teardown — a backstop against unbounded growth, NOT a substitute for this call. retired_seams_ is declared before map_mutex_ and root_, so it destructs last, after the vertex tree and the map lock are already gone: a seam callback whose destructor re-enters the graph re-enters a half-destroyed object and crashes. Such an owner is safe HERE and only here — it must be collected explicitly, never left to teardown.

std::size_t parked_seam_count() const

How many retired value seams are currently parked, awaiting collect.

The observability half of the collector: an embedder that never calls collect has a number it can watch (a health field, an assert in a soak test) instead of a silent, peer-driven leak. Grows by one per retired vertex that BORE a value seam — i.e. one that had any of on_read / on_write / on_children installed at registration, whatever its role_t — and drops to zero on collect. A retired vertex with no value seam parks nothing, including a role_t::HANDLER one registered with an empty handlers_t. On the transport plane that means one per /net/<module>/<name> identity vertex whose link exposes a bus facet (CAN, or a tcp/ws server wired peer_named = true) and zero for every point-to-point connection — so on a default deployment this legitimately never leaves 0.

Evict every subscriber edge a departed link left behind — the graph half of link-teardown eviction (RFC-0009 §D, extended to peer departure).

Walks the whole graph and deactivates + RECLAIMS each active subscriber edge whose stored link NAME equals link_name (the NAME this node addressed the link by — a bus peer’s tag, or a point-to-point child’s registered NAME), unwinding the RFC-0005 listener bookkeeping for each. Local edges and edges of other links are untouched; slot indices of surviving edges never renumber (§D.2), and the freed slots are reused by later appends (vertex_t’s add_edge reuse) — so a redialing peer’s re-subscriptions reoccupy the memory its dead session held instead of growing every vertex’s edge list forever.

A local, host-facing API in the §A.1 sense: no wire operation reaches here — the transport plane calls it when it LEARNS a link died (fwd_router_t:: link_down, the link-departure hook), exactly as the owner’s own logic might. Concurrency: the vertex set is snapshotted under a shared map_mutex_ hold, then each vertex is evicted under its own stripe lock inside a fresh shared hold (never across vertices), so concurrent writes/deliveries interleave freely; an in-flight delivery keeps its route alive by refcount clone (ADR-0041 §2). Safe to call for a link that never subscribed (a no-op).

An EMPTY link_name matches nothing and returns 0. This entry point reports a COUNT and has no error channel, so a nameless link is a no-op rather than a status: a link with no name never subscribed anything. It is a rule, not a coincidence of the comparison — a LOCAL admission stores the empty caller context, so before #1056 an empty key compared equal to every local edge that carried a cold half (the delivery_compact opt-in) and reclaimed it graph-wide.

Parameters:

link_name – This node’s NAME for the departed link; empty ⇒ no-op, 0.

Returns:

The number of edges evicted, summed over the graph.

How many vertices a evict_link_edges for link_name would EXAMINE — the departure’s cost, observable (#1071).

A diagnostic, and the instrument the #1071 acceptance test asserts on: before the per-link index this number was “every vertex in the graph holding any subscriber

edge”, so one peer’s hangup was priced by every OTHER peer’s subscriptions. It is now the count of vertices that peer itself ever subscribed on.

Reports the INDEX’s size, not a live edge count, and the two differ by design: the index is a superset that keeps a vertex after an individual unsubscribe (see link_index_), so this can exceed the number of edges an eviction would actually reclaim. It is an upper bound on work, which is exactly what the scaling property is about — never read it as “how many edges this link has”.

Parameters:

link_name – This node’s NAME for the link; empty ⇒ 0 (a local edge is not reachable by link teardown).

Returns:

The number of candidate vertices, i.e. the departure’s bounded cost.

link_id_t intern_link(std::string_view link_name)

Mint-or-find link_name's interned token — the LINK-UP door (#1266 / #1417).

The transport plane calls this ONCE, when a link or a bus peer becomes audible, caches the answer in its own per-link receive context, and hands it to every subsequent subscribe through op_resolver_t::on_link_id. That is the whole of the carry, and it is what takes the index operation from 14.1–17.2 ns to 9.4 ns — FLAT in link count — and its footprint from 175.8 to 128.0 bytes per link (measured on #1416’s harness, bench/bench_subscribe_index, 8 vertices, best-of-13-rounds against a carried A/A null).

IDEMPOTENT BY NAME, which is the property #1263 pinned and this must not move: the same spelling always answers the same live token, so a redialing peer that comes back under its old name re-enters its old slot rather than stranding it. A name that has never been interned takes a fresh slot (or a released one), and the returned token is valid until release_link or a whole-link eviction retires that slot.

Parameters:

link_name – This node’s NAME for the link; empty ⇒ an invalid token (the #1056 empty-key rule — the LOCAL spelling is not a link).

Returns:

The token, or a default-constructed one for an empty name.

intern_link with a caller-held SLOT HINT — the O(1) door for a caller that has somewhere stable to keep one word (#1437).

intern_link’s mint-or-find goes through the name door, and that door is a LINEAR SCAN of the live slots (the private link_index_’s “DENSE SLOT VECTOR” block spells out why — deliberately NOT an @ref, because that anchor lives on a private nested type the rendered API reference does not emit, and a link to it is a -n -W sphinx failure). Cheaper than the hash it replaced up to about 32 live links and about 2x dearer at 65 — which is fine on the paths the trade was argued on (once per peer hangup) and NOT fine on the un-carried subscribe doors, which pay it per subscribe. This is those doors’ way out: a caller that owns a durable word per link (a registered mount, say) keeps the slot number in it and gets the whole find for one bounds check and one name compare.

The hint is a PURE CACHE with NO invalidation contract, which is the property that makes it safe to park in a structure nobody synchronizes: a stale hint — a slot released and re-minted under another name, a hint from a different graph, an uninitialized zero — fails the name compare and costs one wasted comparison before the scan it would have paid anyway. Nothing a wrong hint can spell produces a wrong token, so the caller owes this word no upkeep on link-down, on re-add, or on teardown.

The generation is NOT part of the hint and must not become part of it. The slot’s name IS the validation: a live slot’s spelling is unique across the index (intern_link is mint-or-find by name) and a dead slot spells itself empty, so a name match already proves the slot is live, is this link’s, and carries the stamp that is current right now — read from the slot under the same lock rather than remembered by the caller.

Parameters:
  • link_name – This node’s NAME for the link; empty ⇒ an invalid token, hint untouched (the #1056 empty-key rule).

  • hint – In: where this name was last seen. Out: where it is now, on any non-empty name that interned — so the next call is the fast path.

Returns:

Exactly what intern_link would answer for link_name.

Retire token's slot so it can be reused — the LINK-DOWN half of intern_link.

Bumps the slot’s stamp, so every copy of token still in flight stops validating and degrades to a name lookup rather than addressing whatever link takes the slot next. The candidate list is released with it: this is the teardown door, so anything still listed is by definition an edge that departed with the link.

Optional in the safety sense and NOT in the footprint sense: a node that never calls it keeps one 64-byte slot per distinct link name it has ever seen, where one that does keeps one per link it currently holds. evict_link_edges already releases the slot it empties, so the transport plane’s ordinary hangup path needs no extra call; this exists for an owner tearing a link down without evicting through the graph.

A token that is invalid, out of range, or already released is a no-op.

How many index inserts had to fall back to a NAME LOOKUP — the carry’s own observability (#1417).

A carried token that validates costs a subscript; anything else costs a scan of the live slots. This counts the second case, and it is the ONLY way to see from outside whether the carry is actually working: a valid token and a name lookup produce byte-identical index state by construction, which is what makes the carry safe and also what makes it invisible.

Expected to be SMALL and bounded, not zero. It counts one per link that has never been interned (the transport plane’s lazy mint goes through intern_link, so that is not counted), one per admission whose key is not the arrival link — a mount-routed target, a field_write caller fallback — and one per subscribe_wire reached with no transport plane behind it, which is every host-side caller and every test that binds an edge directly. The purely LOCAL subscribe() doors never reach here at all: they admit no remote, so there is no link to index them under. A count that GROWS with traffic on a steady link set means the carry is not reaching the index, which is a performance defect, never a correctness one.

std::size_t evict_route_edges(std::string_view link_name, std::span<const std::byte> route_wire)

Evict the remote subscriber edge(s) whose delivery link AND stored return route both match — the refused-route reclaim (#1223 step 5).

The narrow sibling of evict_link_edges, fired by the transport plane when a delivery it emitted draws back an addressed tr::path::invalid refusal (the RFC-0020 bus-residual reject — the one wire observation a producer gets that a stored route’s terminal session departed). Where link teardown reclaims a whole link’s edges, this reclaims exactly the edge(s) that delivered along route_wire over link_name: both keys are required, and the route compare is BYTE-equal on the stored PATH TLV (see vertex_t::evict_route_edges). Same two-phase locking, same RFC-0005 unwind, same no-error-channel contract as link teardown; an empty key matches nothing.

RFC-0009 §D.4 is NOT contradicted: that clause keeps an edge whose target vertex

retired, on the stated premise that “a write to a retired path is not an error the

producer observes”. A refused ROUTE is precisely the case where the producer now DOES observe an error — RFC-0020 (which postdates §D.4) made the observation normative, and this reclaim acts only on it.

Parameters:
  • link_name – This node’s NAME for the link the refusal arrived on.

  • route_wire – The refused route — whole TLV bytes echoed by the rejecting hop: a canonical PATH, or (RFC-0024 §7.1 amendment 1) the bound PATH_REF a reverse-list delivery was refused as. This door classifies the type byte; the per-vertex half stays wire-type-agnostic.

Returns:

The number of edges evicted, summed over the graph.

std::optional<session_anchor_route_t> session_anchor_route(vertex_handle_t vh) const noexcept

Classify vh per the block above: the anchor’s (mount, peer), or nullopt for every ordinary vertex. Lock-free — the key record is immutable.

template<typename Fn>
inline void for_each_vertex(Fn &&fn) const

Visit every REGISTERED vertex once, in ascending canonical-key BYTE order — the graph’s enumeration surface (a census, a directory listing, a paginated /system/… projection).

fn is invoked as fn(wire::key_view_t key, vertex_handle_t vh). key is the vertex’s full canonical key (concatenated NAME records, the PATH payload) rendered on demand — ADR-0057 stores one segment per node, so no such key exists until this asks for it — and is BORROWED for the duration of that one call. Placeholders (the unregistered intermediates a deep register_vertex creates) are SKIPPED: they are addressing scaffolding, not vertices an owner declared, and find does not answer for them either.

SORTED, and there is no unsorted twin, deliberately. The tree walk’s natural order is for_each_descendant’s, which no caller should encode a dependency on; a consumer paginating this surface — “give me vertices 40..60” across two operations — needs the order to be the SAME both times whenever the graph did not change, and byte order over canonical keys is the only order this container can promise that of. The sort is not what costs: every visit needs key, and rendering the keys is already O(n) allocations, so ordering them is a comparison pass on top of work the unsorted form would have done anyway. Offering both would buy nothing and invite the wrong one.

The order is the SAME one the RFC-0008 sweep sets are kept in (pending_ / unconditional_, byte-keyed std::sets), and it earns its keep the same way: the length-prefixed NAME framing makes a parent’s key a byte-prefix of every descendant’s, so a parent always precedes its subtree and that subtree is a CONTIGUOUS run. The result therefore reads as a stable pre-order tree listing.

Concurrency: the {key, vertex} snapshot is taken under ONE shared map_mutex_ hold and fn runs OUTSIDE it — the same two-phase discipline evict_link_edges and the fan-out sweep use. So fn MAY re-enter the graph (read a value, register a vertex, retire something) without self-deadlocking. What it gets in exchange is a SNAPSHOT: a vertex registered after the hold is not visited, one retired during the walk is still visited (handles stay valid — vertices are pointer-stable and never freed, ADR-0057), and a caller that must distinguish those re-reads under its own lock. Vertices registered BEFORE the hold are all visited.

Note

It is byte order over the KEY, not alphabetical order over the spelled path. A NAME record is 02 00 <u16 len> <text>, so siblings sort by name LENGTH first and only then by text (/zone before /sensor before /actuator). A consumer that wants alphabetical DISPLAY order sorts what it collected; what this promises is stability and subtree contiguity.

Warning

CONTROL-PLANE ONLY and priced as such: it allocates one owned key per registered vertex plus the snapshot vector, then sorts. Do not put this on a delivery or write path.

void register_child_type(std::string type, child_factory_t factory)

Populate the device creation catalog (ADR-0017): map a SPEC type selector to a child_factory_t.

A :children[] SPEC write whose type is unregistered returns SCHEMA_NOT_FOUND (the ENOTTY of an unsupported creation). The built-in stored_value type is registered by the constructor.

CONFIGURATION, like the three configure_* sinks: populate the catalog at setup, before frames flow. Unlike them the catalog is a std::map, so #1049’s {fn, ctx} publication does not reach it — a concurrent insert rebalances a tree the in-band creation path may be walking. Registration and lookup therefore take a lock, which costs nothing: both are control-plane cold (one map lookup per created vertex) and neither is on a read, write or dispatch path. Violating the setup-only contract is consequently slow rather than corrupting.

result_t<value_ref_t> read(vertex_handle_t v, std::string_view caller = {}) const

Read a resolved vertex’s stored value (the hot path — lock-free in the LKV slot).

Returns the last-known-value as a rope (ADR-0053 §6): a scalar is the single-link case; a consumer needing contiguous bytes calls rope_t::only() (single-link, zero copy) or rope_t::materialize(). The trailing caller is the ACL caller context (#81): empty for a local API call (the default — zero churn), the inbound link NAME when the FWD resolver drives the op. With no subject resolver installed it costs one null check.

A vertex with ≥ 1 registered child serves the COMPOSED BRANCH READ instead — the folded POINT tree of read_subtree_folded (per-node stored TLVs verbatim, READ-denied subtrees pruned): a view over the existing last-known-value ropes, not a copy. Leaf reads are byte-identical to the pre-composed-read behavior, and a HANDLER target’s on_read seam keeps precedence over the composed read.

result_t<void> write(vertex_handle_t v, rope_t value, std::string_view caller = {})

Write a resolved vertex’s value: assign then deliver (RFC-0008 §D).

Takes a rope; an existing view_t caller compiles unchanged via the implicit view_trope_t. caller is the ACL caller context (see read).

result_t<void> write(vertex_handle_t v, const field_path_t &field, rope_t value, std::string_view caller = {})

Field-write by handle: resolve the vertex_handle_t and field_path_t once, then reuse them on the hot path — no string parse, no map lookup per call.

An empty field is an ordinary value write. Pass path.field() for the field selector. A field write targets a contiguous control TLV, so a multi-link value is materialized first.

result_t<void> assign(vertex_handle_t v, rope_t value, std::string_view caller = {})

Assign a vertex’s value — the STATE transition only, sends NOTHING (RFC-0008).

One of the two irreducible operations write composes: swap v’s last-known-value (atomic), append to the stream ring, bump the write sequence (waking await), and mark v for the next covering propagate sweep (unless v is EXPLICIT, or nobody observes at/above it). WRITE-gated like write; never gated by delivery_mode. A branch POINT decomposes and assigns each descendant (no notify). Pair with propagate for the “update many, propagate once” workflow.

Return values:

SCHEMA_NOT_FOUNDv's role RETAINS NOTHING (a HANDLER), so the state half has nowhere to land and the covering sweep — which takes no value argument and reads the last-known-value — would deliver silence (RFC-0008 Amendment 2). Checked after the WRITE gate. Use write instead: it dispatches the on_write seam and delivers eagerly, which is what a non-retaining vertex can actually do.

result_t<void> propagate(vertex_handle_t v)

Propagate along subscription edges — the EDGE transition only (RFC-0008 §B/§C).

Delivers v’s current value (always — v is the explicit target, so a direct propagate is never gated by v’s delivery_mode) AND the qualifying descendants of v’s subtree per each descendant’s delivery_mode: IF_NEWER descendants assigned since the last covering sweep, and every UNCONDITIONAL descendant. Reads the last-known-value — no value argument. Costs O((pending + unconditional)-in-subtree).

Return values:

SCHEMA_NOT_FOUND – The sweep ROOT retains nothing (a HANDLER) — assign’s refusal, at the other half of the pair (RFC-0008 Amendment 2). Only the root is judged: a sweep rooted at a retaining ancestor still walks a subtree containing non-retaining vertices exactly as before.

result_t<void> propagate(vertex_handle_t v, emission_mode_t mode)

Propagate with an explicit EMISSION MODE (RFC-0025 §4.1.2, Amendment 3 clause 5).

Selection is identical to propagate(vertex_handle_t) in both modes — same delivery_mode gating, same subtree, same drained pending marks. Only the FRAMING differs, and emission_mode_t::PER_VERTEX is exactly the one-arg overload, so the shipped default moves under nobody.

Under emission_mode_t::FOLD the sweep emits one branch-write frame for the swept subtree instead of RFC-0008 §D’s one FWD{WRITE} per selected vertex: the RFC-0016 POINT tree of the selection, node shape byte-for-byte RFC-0005 §B’s (leading NAME, optional VALUE, recursive POINT sub-branches), the root carrying its own leading NAME per §B — the one root asymmetry RFC-0016 §A names between a composed-*read* root and a branch-*write* root. Interior vertices that were not themselves selected appear as value-free skeleton nodes so the tree stays connected; §B calls that a valid no-op node.

The TERMINUS is untouched. RFC-0005 §B’s branch-write slicing already hands each covered subscription point the smallest subview covering every value at-or-below it, so a folded frame needs no new decode path — and this door emits nothing a §B decomposer would refuse. It is ONE FRAME PER SUBTREE, never a container across several: two disjoint subtrees are two calls and two frames (the retired-LIST ban, RFC-0005 §E / ADR-0003).

REFUSALS, all before anything is delivered or any pending mark is drained, so a refused fold leaves the sweep exactly as it found it and the caller may retry PER_VERTEX:

  • TYPE_MISMATCH — a selected vertex’s stored value is not a single trailer-less VALUE TLV. Trailer-carrying nodes are REJECTED rather than silently stripped (§B strictness); this is admissible only because RFC-0025 Amendment 1 moved sample time out of the trailer into payload TIME children. A selected STREAM vertex refuses here too: its since-flush LIST cannot ride a §B node, which admits at most one VALUE.

  • BACKPRESSURE — the fold could not be framed within the injected memory seams.

  • SCHEMA_NOT_FOUND — the root retains nothing; the VERB’s refusal, shared with the one-arg overload, so both modes answer alike for the same root.

Parameters:
  • v – The sweep root; always delivered, never gated by its own delivery_mode.

  • mode – The emission mode.

void set_delivery_mode(vertex_handle_t v, delivery_mode_t mode)

Set v’s per-vertex propagation policy (RFC-0008 §C).

A wiring-time call (the “configure before frames flow” contract), like settings; maintains the sweep’s UNCONDITIONAL membership. Default (unset) is IF_NEWER.

void set_history_depth(vertex_handle_t v, std::uint32_t keep)

Declare how many entries v's STREAM ring retains (RFC-0022 §3.C).

The ring depth is not protocol QoS: it encodes what the APPLICATION wants kept, and only the application can supply it. So it is an owner-side wiring call in the shape of set_delivery_mode and set_app_fields — a declaration the owner makes host-side after registration — and it has no wire surface at all: no peer can read it and none can write it. Callable at any time; the next append trims to the new depth. keep of 0 behaves as 1 (the ring always keeps the last value).

Meaningful on the STREAM role, which is the only role that appends a ring; setting it on another role stores the number and changes nothing. Costs a STREAM vertex zero additional bytes — a STREAM identity already allocates the extension block.

void set_ring_source(vertex_handle_t v, mem::block_source_t *src, bool reliable = false)

Bind the RECEIVING vertex v's own ring source and §4.4 pressure arm (RFC-0025 §4.6.1 clause 3) — owner-side wiring, no wire surface.

A producer never queues; the queue belongs to whoever consumes it, and it is bounded in BYTES by that party’s own injected tr::mem::block_source_t. This is the seam that injects it, sited beside set_history_depth because the two compose: the depth is the owner’s retention INTENT (entries), the source is the BOUND (bytes), and a shortfall surfaces through §4.4’s pressure contract rather than as a silent shrink of the depth.

Charging is reservation ADMISSION, not placement. Each admitted entry reserves its retained width from src and holds it until the entry retires. The payload bytes physically stay with the allocators that already hold them — the shared_ptr zero-copy handoff is preserved and an append is still a refcount bump. Physical placement migration is the later #873 family, not this seam. A reader who assumes the ring’s bytes move into src will be wrong.

Per-injection-point, never a shared pool. ADR-0079’s amendment measured a folded source collapsing to 0.01x of its own single-thread rate at T=24; composition is a knob varied per target, and one receiver running its source dry must not affect another.

REBINDING DRAINS: every queued entry’s reservation is released to the source that served it and the ring is emptied, so this is a wiring-time call like its neighbours.

Parameters:
  • v – The receiving STREAM vertex. Meaningful only on that role; on another it stores the wiring and changes nothing, exactly as set_history_depth does.

  • src – The source to charge against; nullptr restores default_ring_source.

  • reliable – The §4.4 arm. false (default) is BEST-EFFORT: a refused admission sheds the oldest entry whole, accounts the loss, and raises tr::flow::address_shift_gap in order at the shed point. true is RELIABLE: the admission is refused, nothing is shed, the ring never grows past its byte bound, and the LOCAL producer’s write answers status_t::BACKPRESSURE. There is no wire carrier for backpressure in v1 — the per-edge credit window is parked as the v2 escalation (§4.6.1 clause 7) — so a remote producer sees the local receiver’s drop tally, not a stall.

result_t<std::size_t> ring_reserved_bytes(vertex_handle_t v) const

Bytes v's receiver ring currently holds RESERVED against its source — the byte bound’s observable. SCHEMA_NOT_FOUND on a non-STREAM role, matching history.

result_t<std::uint64_t> stream_gaps(vertex_handle_t v) const

Shed points on v's receiver ring since registration — the cumulative tr::flow::address_shift_gap census (RFC-0025 §4.4: a shed with no accounting is non-conforming). SCHEMA_NOT_FOUND on a non-STREAM role.

void set_pin_payload_ratio(vertex_handle_t v, std::uint32_t k)

Declare v's RFC-0022 §3.D pin amplification ratio K (ADR-0042 §3); tr::graph::kPinNever (0, the default) disables pinning on this vertex.

k is a RATIO, not a byte count. A view-delivered WRITE is stored as a refcounted SUBVIEW of the inbound frame — no allocation, no copy — iff payload_bytes * k >= segment_bytes and the payload is trailer-less; otherwise it takes the one-copy trailer-sliced store. Pinning holds the WHOLE inbound segment for the value’s lifetime, so it buys latency and pays in RAM bounded at (k-1)x the payload; that trade is a deployment call, which is why this is an owner-side declaration and not, since RFC-0022 §3.B, a remotely writable knob. Nothing is inherited (§3.F).

What “for the value’s lifetime” costs on a POOLED RX backend: the pin is a borrow of a pool slot — receive capacity — held until the value is displaced, not merely for the delivery window. The library keeps that deferred release safe (atomic segment refcounts); the APPLICATION owns the occupancy budget, since only it knows the pool geometry and the retention pattern. Size against live pinned values x segment_bytes: k bounds the waste per value and never the number of values. Declaring a non-sentinel k on a long-held vertex — a config vertex, a rarely-updated setpoint — is exactly the shape that starves a small pool. See tr::graph::config_t::kPinPayloadRatio for the target-class guidance (NARROW sets the sentinel; WIDE/MID may borrow freely).

Note

This is a per-vertex OVERRIDE of config_t::kPinPayloadRatio, which Amendment 2 fixes at the sentinel on both targets. It exists so §6-style measurement arms rotate inside one process — measuring them as separate binaries is what produced a 2.8x swing on identical code. Setting it IS the opt-in for that vertex; the override’s existence changes nothing shipped, since both defaults are the sentinel.

result_t<value_ref_t> await(vertex_handle_t v, std::chrono::nanoseconds timeout, std::string_view caller = {})

Block until the vertex’s value changes or timeout elapses; return the value.

The READINESS FORM OF A DATA READ (RFC-0008 §A: await observes assigns at its own vertex, in the state plane, independent of propagation) — so after a wake the value is served through the SAME ROLE DISPATCH read runs, and the two doors answer alike at the same instant. A HANDLER vertex therefore answers from its on_read seam (RFC-0008 Amendment 2, correcting a wake that used to answer NOT_FOUND after the awaited write landed); a handler exposing no on_read still answers NOT_FOUND, which is the read contract’s own degradation, not await’s.

The BRANCH fork read takes is deliberately not mirrored: await watches this vertex’s own write sequence, so a branch vertex hands back its own last-known-value, not the composed subtree fold. The READ gate is checked BEFORE the wait, so a denied caller cannot camp on the condition variable.

Returns:

The value, or a status_t (TIMEOUT, PERMISSION_DENIED, NOT_FOUND).

result_t<rope_t> read(vertex_handle_t v, const field_path_t &field, std::string_view caller = {}) const

Field-read by handle (the read dual of the field-write overload).

An empty field is an ordinary value read (the stored rope); otherwise serve :schema, :acl, or a single :subscribers[N] slot (the slot’s stored SUBSCRIBER view, zero-copy) as a single-link rope. For the whole-array :subscribers[] read use read_subscribers. Used by the FWD resolver.

result_t<std::vector<view_t>> read_subscribers(vertex_handle_t v, std::string_view caller = {}) const

Read the :subscribers[] array — the populated slot SUBSCRIBER views in slot order.

Each is a zero-copy refcount clone of the stored source view. The FWD resolver ropes these under a fresh PL=1 wrapper into the REPLY (RFC-0004 §D, no byte copy).

result_t<std::vector<rope_t>> history(vertex_handle_t v) const

Stream history, newest last (Stream role only) — each entry the stored rope value.

result_t<std::size_t> drain_unflushed(vertex_handle_t v, std::vector<std::shared_ptr<const rope_t>> &out, std::uint64_t *gap_before = nullptr)

Drain v's STREAM entries appended since the last flush, in order — a queue, not a coalesce (RFC-0008 §E) — and advance the drain cursor.

The handle-based mirror of the drain half of §E: the same cursor the internal write and sweep paths advance, reachable by an owner that drives propagation itself. It is the observation seam for §E’s “a stream’s flush delivers each ring entry appended since the

previous flush” —

history shows what the ring RETAINS, this shows what is OWED.

The out-param is deliberate, not a returned vector: vertex_t::drain_unflushed

’s #477 nothrow contract is “on OOM return 0 WITHOUT advancing the cursor, so the entries

re-drain on the next covering flush”, and that only holds with caller storage the snapshot can nothrow-reserve into. This form inherits that contract verbatim, including the note that entries trimmed out of the keep-last ring before the drain are lost.

Draining ADVANCES the cursor, so a later propagate sweep will not re-deliver what this took — the caller now owns delivering them.

Parameters:
  • v – The STREAM vertex to drain.

  • out – Caller storage the drained entries are assigned into (overwritten).

  • gap_before – Optional out: shed points on this ring since the previous drain — the in-order tr::flow::address_shift_gap signal of RFC-0025 §4.4/§4.5. Non-zero means entries this consumer would have seen are MISSING immediately before the returned batch. Written whenever non-null, including on a zero drain, so polling a quiet ring still surfaces a shed. Silence is the one behaviour the pressure contract forbids, and this is where it is broken.

Return values:
  • status_t::SCHEMA_NOT_FOUNDv is not a STREAM — no ring, no cursor, the same disposition history gives a non-stream role.

  • status_t::PERMISSION_DENIED – The local caller lacks READ. A drain hands back the SAME bytes history serves, so leaving it ungated would be a READ-gate bypass wearing a different verb’s name.

Returns:

The number of entries drained (0 ⇒ nothing appended since the last flush, or the snapshot could not be allocated — retry on the next flush).

result_t<void> mark_flushed(vertex_handle_t v)

Advance v's STREAM drain cursor to “now” WITHOUT draining (RFC-0008 §E) — an eager delivery already flushed the ring, so a later sweep must not re-deliver.

The handle-based mirror of the flush half of §E, and the exact verb write_branch already uses internally after it fans a decomposed slice out eagerly. Takes no cursor argument: the cursor is the per-vertex “appended since flush” count and the only thing a flush can say about it is “nothing is owed”.

Ungated beyond the role check, unlike drain_unflushed — it discloses no bytes and has no wire surface — the same owner-side shape propagate and set_history_depth carry.

Return values:

status_t::SCHEMA_NOT_FOUNDv is not a STREAM.

result_t<rope_t> read_children_folded(vertex_handle_t v) const

FOLDED projection of the :children listing (L4 fold, Slice 0) — the SAME POINT{ POINT{NAME}… } that the materialized read_children serializes, but produced as a scatter-gather rope (an outer POINT header link plus one link per registered child) instead of one flat buffer.

A read-only projection over the materialized tree — the tree stays the source of truth; this walks it and gathers rather than copying the whole listing into a single allocation. read_children_folded(v).flatten() is byte-identical to the materialized read_children serialize, which folded_children_test gates over many graph shapes. The rope is valid while the graph (and its insert-only, pointer-stable vertices) outlive it. The synthesized-listing case (ADR-0044) has nothing to gather and crosses as a single-link rope. Each member’s NAME bytes are borrowed IN PLACE (zero copy, view::borrow_const) over the pinned child vertex — only the tiny POINT headers are emitted — so the listing is never copied whole.

result_t<rope_t> read_children_materialized(vertex_handle_t v) const

MATERIALIZED :children listing — the flat single-link serialize of the same POINT{ POINT{NAME}… } the fold gathers.

The production field read serves the FOLDED rope; this flat form exists as the independent oracle folded_children_test diffs the fold against (byte identity on flatten() over many graph shapes) — without it the differential would be tautological.

result_t<rope_t> read_subtree_folded(vertex_handle_t v, std::string_view caller = {}) const

COMPOSED BRANCH READ (RFC-0005 §C follow-on): the POINT tree of v's registered subtree, folded as a scatter-gather rope of views over the live last-known-value ropes (zero flatten, zero byte copies).

composed(target) = POINT{ [stored TLV of target]?, child_node* } and child_node(c) = POINT{ NAME(c), [stored TLV of c]?, child_node(grandchild)* } — each node’s value is that vertex’s stored TLV verbatim (the landed LKV bytes, opaque: a non-VALUE TLV such as a STATUS composes as-is; descendant HANDLER on_read seams are not invoked). Unregistered placeholders are skipped exactly as read_children skips them; synthesized on_children transport listings are not graph children and are absent. A vertex the caller may not READ prunes its whole subtree (siblings unaffected). A branch with no descendant values folds to a names-only (topology) POINT tree.

This is what a plain read serves when the target has ≥ 1 registered child; it is public for the same oracle reason as read_children_materialized’s split. Per node: one atomic read_stored() load, LKV links refcount-**cloned** (no byte copy), the child’s NAME record borrowed in place over the pinned vertex, and an owned per-level POINT header (opt.ll auto-widened at the same 0xFFFF boundary as wire::emit_tlv). The walk is an ITERATIVE stack machine over a HEAP-BACKED stack, so it needs no synthetic cap: the bound is the allocator, and exhaustion is BACKPRESSURE.

It does NOT rely on kMaxSegments

, and this comment used to claim it did (“graph depth

is `kMaxSegments`-bounded structurally”). That claim is false:

kMaxSegments is enforced only in path_t::parse (core/src/path.cpp:110), the LOCAL string→bytes builder. ensure_vertex takes raw key bytes and counts nothing, so a write-create already registers a vertex at any depth — locally without limit, and from the wire at whatever depth a branch write’s POINT nesting reaches (RFC-0005 §D amendment 1 took the unresolved-dst arm away, but not decomposition’s landing sites). The iterative walk is safe because it is iterative and resource-bounded — which is the real reason, and the only one that survives kMaxSegments being lifted.

Resolver contract: with a subject resolver installed, acl_allows — and therefore the resolver callback — runs O(nodes) times per composed read under the shared ; a resolver MUST NOT re-enter graph mutation APIs (self-deadlock).

result_t<void> subscribe(const path_t &src, const path_t &target, delivery_policy_t policy = {})

Subscribe src to a target vertex — a write to src re-dispatches the cloned value to target (spec-faithful). NOT_FOUND if src is unknown.

These subscribe(...) overloads are host SDK sugar, not new wire primitives: the wire data API stays read/write/await (ADR-0006). On the wire, subscription is a consumer-initiated SUBSCRIBER write into the producer’s :subscribers[] field (ADR-0026), exactly as connect() is sugar over that field-write. Per ADR-0049 (#59) this overload ENCODES a SUBSCRIBER{PATH} TLV and enters the same :subscribers[] field-write admission door as a wire subscribe — one parse, one SUBSCRIBE gate, one durability latch, and the edge’s stored SUBSCRIBER view reads back byte-identically from :subscribers[].

Parameters:

policy – This subscription’s DELIVERY policy (RFC-0022 §3.A) — the same packed 16 bits a wire subscriber sends in its SETTINGS child, and encoded into exactly that child here so the two doors stay byte-identical. Defaulted to all-zero: best-effort, default priority, no durability request — today’s behaviour for every caller that says nothing.

result_t<subscription_t> subscribe(const path_t &src, subscriber_fn_t fn, void *ctx, delivery_policy_t policy = {})

Subscribe src to an in-process {fn, ctx} callback (sugar; fires inline on each delivery to src with the rope value).

The per-edge sink is a plain function-pointer pair (ADR-0047 hot-path shape, like transport_t::set_receiver), so the per-publish edge snapshot is a trivial copy — no std::function clone. Delivery is value-agnostic (RFC-0008): WHICH vertices a sweep propagates is the source vertex’s delivery_mode, not a per-edge policy. A callback cannot ride a TLV, so this overload skips the door’s parse — but it enters the SAME single admission step (SUBSCRIBE gate → append → durability latch, ADR-0049) as every other door.

Parameters:
  • fn – The per-delivery sink; ctx is passed back as its first argument.

  • ctx – Caller-owned context, passed back to fn on every delivery. Its lifetime is bounded by this build’s reclamation policy (ADR-0080), not by prose: keep it alive until unsubscribe releases it — under the default tr::graph::reclaim_local_t that is before unsubscribe() returns when called from outside a delivery, and before the enclosing write() returns when called from inside one. Pass a tr::graph::subscriber_release_fn_t to unsubscribe(const subscription_t&, subscriber_release_fn_t) to be TOLD which; there is no in-flight state to poll. See subscription_t.

  • policy – This subscription’s DELIVERY policy (RFC-0022 §3.A); defaulted to all-zero, i.e. today’s behaviour. A callback edge carries no TLV, so the policy is set on the slot directly rather than parsed out of one.

Returns:

A subscription_t handle for unsubscribe; error on an unknown src or a denied SUBSCRIBE gate.

template<typename F>
inline result_t<subscription_t> subscribe(const path_t &src, F &callback, delivery_policy_t policy = {})

Subscribe src to a caller-owned callable (sugar over the {fn, ctx} form).

Zero-erasure sugar mirroring transport_t::set_receiver: callback is bound by address (lvalues only — a temporary would dangle). Its lifetime bound is the {fn, ctx} form’s, since it IS the ctx: it must stay alive until unsubscribe releases it, which this build’s reclamation policy (ADR-0080) pins to a moment the library reaches on its own — see subscription_t.

Parameters:

policy – This subscription’s DELIVERY policy (RFC-0022 §3.A); all-zero default.

Returns:

A subscription_t handle for unsubscribe (as the {fn, ctx} form).

result_t<void> unsubscribe(const subscription_t &sub)

Remove the in-process subscription sub returned by subscribe.

The host-SDK-sugar counterpart of the wire :subscribers[N] clear (ADR-0049): it deactivates the edge slot and unwinds the RFC-0005 listener bookkeeping (descendants’ writes stop bubbling to the producer sub names), exactly as the wire path does. The shell stays (index-stable) and a later subscribe reuses it. Idempotent-ish: a default-constructed or already-cleared handle returns NOT_FOUND.

On the . Retirement takes effect at once — the next snapshot skips the slot — but a fan-out ALREADY walking a snapshot still names the retired {fn, ctx} pair. Under the default tr::graph::reclaim_local_t there is exactly one case where that can be true of THIS call: unsubscribing from inside a delivery. So when this overload is called from outside any delivery — the ordinary case — it returns already quiescent and the caller may free its ctx on the return. Called from INSIDE a delivery it cannot tell the caller when the pair died, because it was given no way to: use the two-argument overload below, which is the form that carries a signal. Under tr::graph::reclaim_strict_t re-entrant unsubscribe is forbidden outright, so this overload is always quiescent on return.

Note

Applies to the callback-form subscriptions; a path→path (subscribe(src, target)) edge is a wire :subscribers[] field-write, removed via that wire clear.

result_t<void> unsubscribe(const subscription_t &sub, subscriber_release_fn_t release)

Remove the in-process subscription sub and be TOLD when its ctx is dead — ADR-0080’s event-driven half.

Identical to the one-argument overload in what it retires; it adds the one thing that overload structurally cannot provide, a signal. release is invoked exactly once with the subscription’s callback_ctx, on THIS thread, outside every graph lock, at the grace point the bound tr::graph::default_config_t::reclaim_policy_t names:

bound policy

called from OUTSIDE a delivery

called from INSIDE one

tr::graph::reclaim_strict_t

inline, before this call returns

forbidden

tr::graph::reclaim_local_t

inline, before this call returns

before the enclosing

write() / propagate() returns | | tr::graph::reclaim_qsbr_t | inline when NO participant is mid-dispatch | once every participant has passed a quiescent state — possibly on another thread |

So the caller frees its context from release and never asks a question about in-flight state — the library owns that tracking. A hook is run ONLY for a call that actually retired an edge: a NOT_FOUND return (a default-constructed handle, an already-cleared slot) owes no signal and runs nothing.

Note

A deferred hook needs one of the tr::graph::default_config_t::kDeferredReleaseSlots parking slots — this thread’s, or under reclaim_qsbr_t the shared table’s. If every one is taken the pair is DROPPED and the hook never runs — a deliberate leak in preference to a use-after-free — and deferred_release_drops counts it.

Parameters:
  • sub – The handle subscribe returned.

  • release – The release hook; nullptr degrades this to the one-argument overload. It must not itself unsubscribe the same handle, and it runs on whichever thread reached the grace point.

Returns:

{} on success; NOT_FOUND when sub names no active edge — and then release is not called.

void set_app_fields(vertex_handle_t v, std::vector<app_field_t> table)

Install (or replace) v's field descriptor table — the OWNER declaring its application property fields under :settings.app. (RFC-0010 §A).

A local, owner-facing host API, the mirror of register_vertex (the RFC-0009 §A.1 doctrine: the field catalog is device state, so there is no wire operation that declares a field) — remote peers write DECLARED fields, per their declared app_access_t and under the vertex WRITE right, never invent them; every undeclared name keeps SCHEMA_NOT_FOUND (the ENOTTY default). Entries may carry an initial value and the §B.1 descriptor bytes read :schema serves verbatim (after the runtime-projected access member). Replacing the table is atomic with respect to concurrent field operations on v; an empty table uninstalls (back to the closed pre-RFC surface). Callable at any time — declaration is not one-shot. App-field writes never wake await and never propagate (§C): a change consumers should notice is followed by the owner’s ordinary announce write.

result_t<void> set_identity(std::uint8_t kind, std::span<const std::byte> key)

Install this NODE’s identity — the key read <vertex>:identity serves (#406, RFC-0011; ADR-0045 decision 3 “the public key *is* the identity”).

NODE-scoped, not per-vertex: a node is one path tree, so EVERY vertex of this graph answers :identity with the same byte-identical record. That invariant is the whole point — it is what makes the record a valid CROSS-PATH key, so a client walking /b and /c/a/b can prove they are one device (ADR-0044 point 3: the core never dedups; the client does, keyed by an identity it chooses — this is that key).

NO CRYPTO IS INVOLVED HERE, deliberately. The record is a claim: this seam stores and serves bytes the owner supplies and verifies nothing. Proving a node HOLDS the key is authentication (the ADR-0045 challenge/Noise handshake) and lives elsewhere; a claim is nevertheless exactly what a TOFU peer needs to pin, and what a topology walk needs to dedup. Treat an unpinned identity accordingly.

Idempotent and re-callable; the last install wins. CONFIGURATION, like register_child_type — install before frames flow.

Install, clear_identity and read_identity nevertheless serialize on one lock (#1049), because this is the one member on that list whose READ is served ABOVE the READ gate — an unauthenticated peer may pin the key on first use (RFC-0011 §C), which is deliberate. The read memcpys the stored record, so a rotation racing it would otherwise be a remotely-reachable use-after-free. All three verbs are cold, so the lock is invisible; a runtime rotation is therefore SAFE here, merely outside the doctrine.

Parameters:
  • kind – The RFC-0011 §B identity-kind (0x01 = ed25519 raw public key).

  • key – The raw public key. Length MUST match kind (ed25519 ⇒ exactly 32).

Return values:

TYPE_MISMATCHkind is outside the registry (0x00 is reserved-invalid), or key’s length contradicts kind.

void clear_identity()

Drop this node’s identity — :identity reverts to SCHEMA_NOT_FOUND.

The keyless state is the surface being ABSENT, not empty (RFC-0011 §C.3): a node without a keypair genuinely has no identity facet, which is the ENOTTY of an unsupported field, byte-for-byte the pre-RFC behaviour.

void set_app_fields_static(vertex_handle_t v, borrowed_fields_t table)

Install (or replace) v's field descriptor table from BORROWED, static-storage declarations (ADR-0058) — the same owner-facing semantics as set_app_fields, but the name/descriptor bytes are VIEWED, never copied.

For an MCU owner whose field table is constexpr in flash, this costs zero declaration RAM: the runtime views table itself, so the caller MUST keep and the bytes it points at alive for the vertex’s lifetime (pass a static/constexpr array in flash / .rodata, never a stack array or a soon-freed heap block). Note this is the ARRAY as well as its bytes — an earlier revision copied table's entries into an owned vector, so only the bytes had to outlive the vertex, and the “zero declaration RAM” above was untrue by ~200 B per vertex on host (ADR-0058 erratum 1; measured by the vertex_app5_static gate row). Declaration only — no initial value; write values later through the field-write surface. Empty table uninstalls, exactly as set_app_fields. Wire-invariant: :schema serves the same verbatim bytes as the owning overload.

table is a borrowed_fields_t, which converts implicitly from the array spellings a static table takes and NOT from a std::vector — so the erratum-1 lifetime tightening lands on a stale caller as a compile error rather than silently (ADR-0058 erratum 2). A runtime-sized table opts out via borrowed_fields_t::unchecked.

void configure_remote_delivery_sink(remote_delivery_fn_t fn, void *ctx) noexcept

Install the sink the producer fan-out hands each REMOTE subscriber’s delivery to (#136, RFC-0004 §D/§E.1).

CONFIGURATION, not a runtime knob (#1049): install it at wiring time, from ONE thread, before frames flow. The verb is named configure_ to say so in the API rather than in a comment asking callers to be careful — tr::net::fwd_router_t’s constructor installs it, and a router constructed against a graph that is already serving frames is UNSUPPORTED. The sink then fires on whatever thread calls write (outside the vertex lock), and on subscribe for a transient-local latch. L4 keeps it as an opaque function pointer, so the graph never depends on a transport. A null fn (the default) ⇒ remote slots are stored but never deliver. The value reaches the sink as a rope (ADR-0053 §6): a single-link value materializes zero-copy, a multi-link value is handed over as the rope it is.

The pair is published through a tr::sink_slot_t, so violating the contract is DEFINED rather than undefined: a fan-out racing an install either sees the whole new pair, the whole old one, or no sink for that one edge — never a new fn beside a stale ctx, and never the freed capture state the std::function predecessor could hand it. What the slot does NOT do is stop a dispatch already in flight, so ctx must outlive every write that can still reach the fan-out.

Parameters:
  • fn – The sink; ctx is handed back as its first argument. Null clears.

  • ctx – Caller-owned context; must outlive every possible dispatch.

void configure_subject_resolver(subject_resolver_fn_t fn, void *ctx) noexcept

Install the pluggable subject resolver (ADR-0018) — the ACL enforcement switch.

No resolver (the default) ⇒ enforcement is DISABLED: every operation is allowed, exactly today’s behavior, and the hot path pays one null check. With a resolver installed, each gated operation with a NON-EMPTY caller context maps it through the resolver and — when a subject token comes back — evaluates the target vertex’s effective ACL (own ACEs + ancestor ACEs carrying INHERIT, ADR-0020): allowed iff some non-expired ACE with a matching subject (or "EVERYONE@") grants the operation’s right bit; a vertex whose effective ACL is empty stays open (enforcement is opt-in per vertex via ACL presence). Denial returns status_t::PERMISSION_DENIED (tr::access::denied on the wire, RFC-0002).

The EMPTY caller context is the local-API convention and is trusted WITHOUT consulting the resolver (#905) — a remote op always carries its inbound link NAME, so it cannot spell the trusted context. The resolver’s own error arm is therefore free to mean DENY: an unresolvable caller is refused, not waved through.

The wildcard spelling is RESERVED against the resolver’s OUTPUT (#908): a token equal to tr::graph::kEveryoneSubject is not a principal — that caller is refused at every gate, guarded vertex or not — because the wire has one spelling for a subject token, so a resolver that passes a caller-supplied identity through could otherwise mint a principal indistinguishable from the wildcard ACE.

CONFIGURATION, not a runtime knob (#1049): install it at wiring time, from ONE thread, before frames flow — which the verb’s name now says, and the {fn, ctx} shape makes safe to get wrong. The gate reads the pair through a tr::sink_slot_t: with no resolver that is ONE relaxed load, exactly what the null check cost, and with one installed the gate dispatches from a coherent snapshot, so a concurrent install/replace can neither pair a new fn with a stale ctx nor free the state a running resolver is standing on. ctx must outlive every gated operation.

Parameters:
  • fn – The resolver; ctx is handed back as its first argument. Null (the default) DISABLES enforcement.

  • ctx – Caller-owned context; must outlive every gated operation.

void configure_subscription_observer(sub_observer_fn_t fn, void *ctx) noexcept

Install the EXTERNAL subscription observer — a callback fired on every :subscribers[] mutation that arrived over a transport.

The app-side answer to “who is watching what, right now”: a producer that wants to spin up a source only while a peer is subscribed, an inventory of live remote subscriptions, a projection of the fan-out graph. Today that is discoverable only by polling read_subscribers over every vertex; this is the edge-triggered form.

Fires from the ONE admission door every subscribe lands in (ADR-0049 admit_subscriber) and from the :subscribers[N] clear, so an append, a [N] replace (a REMOVED for the displaced edge then an ADDED) and a clear are all reported, whichever wire shape carried them — the wire :subscribers[] APPEND that binds a REMOTE subscriber (subscribe_wire, target empty) and the one that names a LOCAL target alike.

Only EXTERNAL mutations fire it — see sub_event_t for what that means and why. Two further silences are by design, not oversight:

  • evict_link_edges — the transport-plane hook that drops a departed link’s edges wholesale (RFC-0009 §D) — emits NOTHING. It is a local host API, not an op, and it clears k edges of one link in a batch. An app tracking live subscriptions must therefore treat its own link-down signal as the removal for every edge of that link.

  • unsubscribe(subscription_t) is a local door and stays silent like the rest.

CONFIGURATION, not a runtime knob (#1049): install it at wiring time, from ONE thread, before frames flow — the configure_remote_delivery_sink / configure_subject_resolver contract, now stated by the verb and enforced by the {fn, ctx} shape rather than requested in a comment. A null fn (the default) costs one relaxed load on the subscribe path and nothing anywhere else. ctx must outlive every subscription mutation the graph can still report.

Parameters:
  • fn – The observer; ctx is handed back as its first argument. Null clears.

  • ctx – Caller-owned context; must outlive every reportable mutation.

void configure_wire_target_resolver(wire_target_fn_t fn, void *ctx) noexcept

Install the wire SUBSCRIBER target resolver (RFC-0021) — the transport plane’s mount descent, borrowed by the :subscribers[] wire door.

With no resolver (the default) a wire SUBSCRIBER’s PATH child is inert, which is every pre-RFC-0021 embedder’s behaviour, unchanged. With one installed, subscribe_wire asks it whether the target routes through a mount and, when it does, binds the edge to (that mount, the residual below it) instead of to the session the subscribe-write arrived on — which is what lets a third party wire a flow between two OTHER nodes and then depart (#491).

CONFIGURATION, not a runtime knob (#1049): fwd_router_t installs it in its constructor, so a node with a transport plane has it and one without cannot.

Parameters:
  • fn – The resolver; ctx is handed back as its first argument. Null clears.

  • ctx – Caller-owned context; must outlive every wire subscribe.

The wire :subscribers[] APPEND — the same admission door as the local sugars and field-writes (ADR-0049), plus the remote delivery binding.

Called by the FWD resolver on an inbound :subscribers[] WRITE (#59/#136); it replaces the retired add_remote_subscriber parallel API. source_view (the SUBSCRIBER TLV, an owned copy) is parsed ONCE here — the delivery_compact opt-in comes from this parse (the resolver no longer parses it in parallel) and the view is retained zero-copy so a :subscribers[] read serves it back. A PATH child, if present, names the consumer at ITS origin and is deliberately NOT bound as a local re-dispatch target — remote delivery rides return_route (a view over a refcounted segment — the ONE copy of the route; every later delivery clones the refcount, ADR-0041 §2) over link via the remote sink. Admission is the single ADR-0049 step: SUBSCRIBE gate on v's :acl under link (#81, ADR-0026, PERMISSION_DENIED on denial) → slot append → durability latch (if the parsed delivery_policy_t sets durability_request and v holds a value, the LKV is latched to this subscriber — one synchronous sink call, RFC-0004 §D / RFC-0022 §3.A).

return_route MUST be non-empty — an empty one is INVALID_PATH (#1055). This door is the only one that binds a link for delivery, so it is where the two fields are held to ONE meaning: an edge that carries a link carries the route to deliver over it. The fan-out body (dispatch_edge) therefore tests the link alone and hands the sink the route unchecked, which is what keeps that deliberately-inlinable per-edge test at one comparison; admitting a routeless edge instead bought a FWD{WRITE} with a zero-byte dst on every publish. Both in-tree callers already satisfy this (the resolver rejects a failed route copy as BACKPRESSURE, fwd_router_t::subscribe_toward refuses an empty residual as INVALID_PATH), so the door narrowed to what the wire already produced.

reverse_route, when non-empty, is the COMPLETED reverse-direction bound route (RFC-0024 §7.1 amendment 1): a PATH_REF TLV whose element 0 is THIS node’s own reference to the connection vertex the subscribe arrived on, followed by the elements the forwarding hops contributed. Stored beside return_route as the delivery optimisation + liveness check; empty (the default, and every pre-amendment caller) keeps the subscription canonical-only, byte-identical to before.

caller is the SUBJECT this admission is gated under and the fan-in context every later delivery through the edge re-gates under — who subscribed, as against link's where to deliver (ADR-0082 §Decision 1; the two were one string until #375 Part 2). EMPTY means “the same as @p link”, which is every pre-split caller and is byte for byte what this door did before: a FLAT listener’s peers all subscribed as their shared link name. A terminus that derived a per-writer subject from the frame’s peer_handle_t passes it here, and then the SUBSCRIBE gate, the stored subscriber_remote_t::caller and each delivery’s WRITE re-gate all name the writer rather than the wire it used. The delivery link is unaffected, which is the point of splitting them: the edge still routes back over link (or, for a mount-routed target, over the mount).

link_token is the CARRIED interned identity of link (#1266 / #1417) — what the transport plane got back from intern_link at link-up and cached in its own per-link receive context. It saves the index the name hash and find — 42–56 % of the whole index operation net of its control, measured at all ten cells of #1416’s own harness. A token that cost NOTHING to obtain would be worth 72–82 %, so roughly two thirds of that ceiling survives paying for the carry. It is an OPTIMISATION and only that: the default is “no token”, which is byte-identical to every pre-carry caller, and a token that does not name a slot holding the key the index is about to use is ignored rather than trusted. That is deliberate — the key is not always link (a mount-routed target rebinds it to the mount’s, and a field_write admission has only its caller, #943), and a token silently indexing under the wrong link is exactly the leaked subscriber edge #1071 exists to prevent.

result_t<value_ref_t> read(const path_t &path) const

Read by path — resolve the path key once (guarded map lookup), then the hot path.

A read whose path has a field tail (e.g. :settings.app.kp, :subscribers[], :schema) is routed to the field surface.

result_t<void> write(const path_t &path, rope_t value)

Write by path — resolve the key once, then write(vertex_handle_t, rope_t,std::string_view).

result_t<value_ref_t> await(const path_t &path, std::chrono::nanoseconds timeout)

Await by path — resolve the key once, then await(vertex_handle_t,std::chrono::nanoseconds, std::string_view).

std::optional<vertex_handle_t> find(std::span<const std::byte> key) const

Resolve a canonical PATH-payload key to its vertex handle (nullopt if unknown).

std::uint32_t pin_payload_ratio(vertex_handle_t v) const noexcept

v's declared RFC-0022 §3.D pin amplification ratio K (ADR-0042 §3); tr::graph::kPinNever (0) ⇒ this vertex never pins.

The read accessor the opaque handle does not expose directly: the WRITE resolver (op_resolve_walk.hpp) queries it here instead of dereferencing the vertex. One inline load, and nothing is inherited (RFC-0022 §3.F) — a vertex whose owner never called set_pin_payload_ratio answers 0 whatever its ancestors hold.

result_t<vertex_handle_t> ensure_vertex(std::span<const std::byte> key, std::string_view caller = {})

Find-or-create the vertex at key (write-creates, RFC-0005).

Resolves key; when absent, creates the vertex — and every missing intermediate level, mkdir -p style, each a STORED_VALUE vertex — gated by the CREATE right on the nearest EXISTING ancestor’s effective ACL under caller (PERMISSION_DENIED when denied; a graph holding no ancestor at all is open, matching ACL-presence opt-in). A creation race lost to a concurrent caller is benign (the winner’s vertex is returned). key must be a well-formed, non-empty canonical PATH-payload (else INVALID_PATH).

Note

This is the LOCAL creation door and the branch-write decomposition’s landing door — NOT the remote miss handler. Since RFC-0005 §D amendment 1 (#1139) a peer’s fieldless FWD{WRITE} to an unresolved dst answers NOT_FOUND and never reaches here; a peer creates through the ADR-0059 creator endpoint. The asymmetry is deliberate: the in-process caller owns its graph’s structure.

Note

No SCRATCH allocation. The level walk stores nothing and the registration takes borrowed key bytes, so the per-call temporaries that used to scale with the key’s DEPTH — the part a peer chose the size of — no longer draw from the global heap behind the injected block_source_t’s back (#1139, #873). The vertex_t objects themselves are still heap-allocated; that is the larger #873 arena question.

result_t<void> hide_from_enumeration(vertex_handle_t vh)

Drop vh out of its parent’s :children[] listing, keeping it registered and addressable — the enumeration-hide seam RFC-0014 §3 requires (stage S4 of #492).

The one caller today is tr::net::transport_vertex_t, on the creator endpoint <net_root>/<module>/conn: §3 reserves that name and says it is hidden from <net_root>/<module>:children[], which “returns the member connection vertices”. The endpoint is not one of them — it is the write-only control that CREATES them — so a peer walking the listing as a topology of links descends into a vertex with no peer behind it. Discovery of the endpoint is by §6’s creatability probe (read <module>/conn:schema) instead, which is why hiding must not cost addressability.

Scope, deliberately: this changes :children[] (both the materialized and the folded door) and nothing else. find still resolves the vertex, reads and writes still reach it, the RFC-0016 composed branch read still descends into it, and the owner-side for_each_vertex census still visits it. RFC-0014 §3’s clause is about the member listing; widening it to the other surfaces is not this seam’s call to make.

One-way. The bit belongs to the current occupant of the key, so a retire clears it along with the rest of that occupant’s identity; a fresh registration is listed again unless it hides itself. There is no unhide.

Parameters:

vh – The vertex to hide. Must be registered.

Return values:

status_t::NOT_FOUNDvh is null or is an unregistered placeholder — hiding a vertex that is not a member yet would silently apply to whatever registers there later.

std::uint64_t ancestor_walks() const noexcept

How many writes performed the ancestor (bubbling) walk — instrumentation.

The near-free-when-idle observable (RFC-0005): stays 0 while no subscriber exists above any written vertex, so tests and benches can assert a write never walks ancestors unless someone is listening. Relaxed monotonic counter.

std::uint64_t target_canonical_resolves() const noexcept

How many target-edge deliveries fell back to the canonical find_ptr walk instead of the minted binding (#830) — instrumentation.

The inverse of a hit counter ON PURPOSE: this is the only path #830 leaves paying the O(depth) resolve, so counting it costs the fast path nothing at all — no atomic on the bound leg. Non-zero means one of: the edge was admitted before its target existed (or against a placeholder / saturated generation, so no mint was possible), or the binding went stale and deref_vertex_slot refused it. Relaxed monotonic counter, and the observable an ablation uses to prove the bound leg is the one actually running.

delivery_drops_t delivery_drops() const noexcept

Snapshot the per-cause delivery-drop counters (delivery_drops_t).

The loads are individually relaxed and not one atomic snapshot, so a reader racing a delivering thread may see a torn total. That is deliberate: making it coherent would put a lock on the drop path to serve a diagnostic, and these are monotonic counters whose useful reading is “is this growing”, not an instant.

void count_external_drop(external_drop_t why, std::uint64_t n) noexcept

Count n deliveries an off-graph deliverer declined, into delivery_drops.

The ONE public door to the drop counters (#1068). The net plane performs deliveries the graph never sees — a COMPACT terminus resolves a label to a vertex and writes it — so the drops on that path are invisible to every counting site inside graph_t. This is a method rather than a friendship because the counters are a public, documented surface while the internal drop sites are not: a deliverer needs to add to the published numbers, not to reach into the machinery that maintains them.

n is a delivery count, never an event count, exactly as for the internal sites: a deliverer that sheds N deliveries counts N. Relaxed monotonic; costs nothing when nothing is dropped.

Public Static Functions

static std::uint64_t deferred_release_drops() noexcept

How many retired {ctx, release} pairs this PROCESS has dropped for want of a parking slot — each one a release hook that will never run (ADR-0080).

The observability half of the bounded park: a leak is the safe answer to an exhausted bound, but a silent one is not. A non-zero reading means tr::graph::default_config_t::kDeferredReleaseSlots is undersized for how many subscriptions this node retires from inside a single delivery stack — raise it in the override fragment. It is process-wide (summed across threads) and monotonic, and it stays 0 forever on a node that never unsubscribes re-entrantly, which is most of them.

Under tr::graph::reclaim_qsbr_t it counts the SHARED retired table’s drops and means something sharper: that domain republishes and rescans before it gives up, so a non-zero reading says a participant thread genuinely never reached a quiescent state while the table filled. That is an embedder defect — a dispatching thread that never returns to its event loop, or more concurrent dispatchers than tr::graph::default_config_t::kQsbrParticipants — and this counter is how it surfaces.

static inline void thread_quiescent() noexcept

Declare that this thread holds no in-flight delivery — the OPT-IN quiescent point of a cross-thread grace period (ADR-0080, #1376).

No policy’s guarantee depends on an embedder calling this

, and that is deliberate: it would otherwise contradict ADR-0080 §Decision 4 and the reference article’s “there is no

verb the embedder must remember to call”. Every dispatching thread announces and drains AUTOMATICALLY at its outermost dispatch exit, which already covers ADR-0080 §Decision 3’s named case — an RX thread that has finished a message and returned to its event loop.

It exists for the thread the automatic path cannot reach: one that mutates LKV slots (displacing nodes onto its own private retired list) without ever dispatching, and one that wants a stated teardown precondition before an injected std::pmr resource dies (ADR-0039 §Erratum 8’s “domain quiescence point”, which #897 asks be nameable). Call it at the top of an event loop, or once before joining such a thread.

Under the two per-thread policies it is an empty inline function — it compiles to literally nothing, and graph.cpp is not even aware of it. It must NOT be confused with collect, which is the ADR-0072 value-seam park and answers an unrelated question; this verb is not a poll of in-flight state and returns nothing.

Public Static Attributes

static constexpr std::size_t kNoVertexCeiling = static_cast<std::size_t>(-1)

set_vertex_ceiling’s “no ceiling” value, and its default.

struct delivery_drops_t

Why a delivery was declined, counted per cause.

A path-target edge — the form a wire SUBSCRIBER produces, naming a target PATH — delivers by re-dispatching into that target. Three conditions make that impossible, and all three are specified to DROP the one delivery rather than fail the write: the write itself succeeded and the other legs still ran. Dropping is correct. Dropping invisibly is what these counters fix — a node whose target was retired, or whose fan-in gate denies the edge’s stored caller, otherwise drops every delivery for the rest of its life with nothing anywhere to say so.

Two counters reach past that edge, because the same blindness was reachable from the net plane (#1068). A COMPACT terminus delivery is a write like any other, and the router that performs it discards the status: an :acl that refuses the inbound link, a route that no longer resolves, or an allocation that fails under pressure each shed a frame that an operator could not see. count_external_drop is the door that plane counts through, and denied is counted at the graph’s own WRITE gate so it is one number for every plane rather than one per deliverer.

The drop is not always ONE delivery, and the counters say so by counting deliveries rather than events (#896): a fan-out truncated by an unreservable overflow buffer sheds every edge past the inline prefix, and an assign whose pending mark cannot be allocated sheds the vertex’s WHOLE subscriber set — each shed delivery is one increment, so 1 never stands in for N. A handler write’s notify clone used to be the widest of these; #1505 deleted the clone rather than the tally, so that shed is now impossible rather than counted, and the width rule is unchanged by its removal.

Counted, never enforced: nothing in the library reads them, so a deployment chooses whether to alarm. Relaxed monotonic, incremented only ON a drop — the delivering path pays nothing when nothing is dropped, exactly like ancestor_walks.

Public Members

std::uint64_t no_target = 0

The target PATH resolved to no live vertex (retired, or never created) — a subscription edge’s target, or a net-plane route that no longer names one (count_external_drop).

std::uint64_t denied = 0

A WRITE was refused by the target’s :acl (#81, #1068). Counted on EVERY plane the value-write path is entered from — an API write

, a FWD{WRITE} terminus, a COMPACT terminus, and a subscription edge’s fan-in gate — so this is “refusals”, not “refusals nobody was told

about”: an API caller both receives

PERMISSION_DENIED and counts here. Deliberately NOT counted: assign (the no-delivery state half), a control-plane field write, and a denied READ — each a different right or a different path, and folding them in would make one number mean four things.

std::uint64_t out_of_memory = 0

The nothrow delivery clone / edge-view copy could not be allocated (#477) — one count per delivery shed, whatever the fan-out width.

std::uint64_t fan_out_truncated = 0

Deliveries shed because a wide fan-out’s snapshot could not be widened past the inline prefix — a capacity degrade, not an allocation failure on the delivery itself (vertex_t::snapshot_drops_t::truncated).

struct session_anchor_route_t

The (mount, peer) a SESSION ANCHOR names, or nullopt for every ordinary vertex — the reverse-list delivery’s egress question (RFC-0024 §7.1 amendment 1, #1223).

A bound delivery’s LAST element dereferences to the accepted session’s identity vertex (the #1254 anchor); the hop that consumes it must egress to the SESSION, and this is where it learns which one. Classification is by the anchor’s own key shape — the id is :<mount>/<peer>, and both : and / are characters path::valid_segment forbids, so no addressable vertex’s key can ever satisfy it (the same argument that makes the anchor unspellable makes this test unforgeable). The views are BORROWED from the vertex’s immutable key record and stay valid for the graph’s life (vertices are never freed).

Public Members

std::string_view mount

The bus child’s registered NAME.

std::string_view peer

The accepted session’s routable name.

class vertex_t

An L4 graph vertex: a named, addressable position holding a value, a bounded history, or a user handler (docs/reference/11 §roles).

Pinned in place (the atomic last-known-value slot + mutex + condvar are non-movable) and always handled via a vertex_handle_t returned by graph_t::register_vertex (ADR-0056). The read/write hot path takes no vertex lock (an atomic shared_ptr swap); the mutex guards only the history ring, the subscriber list, and the await waiter accounting. Non-copyable.

The public surface is a VERB interface — reading the stored value (read_stored), readiness (note_write / wait_for_change / the seq cursors), edges (add_edge / clear_edge / snapshot_edges), and ACL state (set_acl / with_acl / with_aces / with_effective_aces) — each verb taking the vertex mutex internally (the LKV slot stays lock-free). graph_t keeps what SPANS vertices: routing, ancestor walks, fan-out dispatch legs, the effective-ACL walk, admission, and the field surface.

The PUBLISHING half of storage is not on that surface: store, like the map-lock mutators, is private with graph_t as its sole friend (#867, #1300), because every publish must pass graph_t::store_value — the one seam that gates the write, injects the ADR-0039 resource and counts what the publish shed. Owners reach the stored value’s queue semantics through graph_t::history / graph_t::drain_unflushed / graph_t::mark_flushed instead.

Public Types

enum class edge_replace_t

Outcome of replace_edge — tells the caller which bookkeeping it owes.

Values:

enumerator OUT_OF_RANGE

No slot idx exists; nothing was written.

enumerator FILLED_EMPTY

The slot existed but was cleared — this is an ADD.

enumerator REPLACED_ACTIVE

A live edge was swapped out; the listener count is unchanged.

enum class app_read_t

One app-field read outcome — the graph maps these onto the RFC-0002 identities (SCHEMA_NOT_FOUND / NOT_FOUND).

Values:

enumerator UNDECLARED

No such field in the table (or no table) — ENOTTY.

enumerator WRITE_ONLY

Declared wo — no read surface (RFC-0010 §A.4).

enumerator UNSET

Declared but never written and no initial value.

enumerator OK

Value copied out.

Public Functions

inline vertex_t(role_t role, path_key_t name, handlers_t handlers)

Construct a vertex with its role, own canonical NAME record (ADR-0057 — one segment, not the full key), and handlers. The cold extension block is allocated only if this identity needs one (#361 §1).

inline ~vertex_t()

Free the cold extension block (allocated at most once, ADR-0057 lifetime) and flush the edge block’s published + parked arrays (edge_block_t’s destructor states the outlive-the-publishers contract that makes this safe).

inline role_t role() const noexcept

This vertex’s behavioral role.

inline const path_key_t &name() const noexcept

This vertex’s own canonical NAME record (its single path segment, ADR-0057); empty at the root. The full key is a parent-walk concatenation (graph_t’s build_key).

inline bool has_extension_block() const noexcept

Whether the lazily-allocated cold extension block EXISTS on this vertex (#361 §1).

A RAM-census observable, not a data-plane predicate: it is the one fact about the pay-for-what-you-use split that no functional surface reveals, and bench_qos_census plus the RFC-0022 host tests are its only callers. It used to be spelled by comparing settings()’s returned ADDRESS against the shared kDefaultSettings constant; RFC-0022 deleted both, so the question needs a name of its own rather than an idiom.

inline std::uint32_t pin_payload_ratio() const noexcept

This vertex’s declared pin amplification ratio K (0 ⇒ never pin, the default).

ONE inline load and nothing more: it is read on EVERY view-delivered write (op_resolve_walk.hpp), so it may never become an ancestor walk. Nothing is inherited (RFC-0022 §3.F) — a vertex that was never given a ratio answers 0, whatever its ancestors hold.

inline const value_handlers_t &handlers() const noexcept

This vertex’s user handlers (Handler role behavior + the on_children seam); an all-empty shared constant when no extension block.

inline std::function<void(std::string_view, const view_t&)> on_app_field_write()

A copy of this vertex’s owner apply seam (RFC-0010 §A.3), or empty when none — taken under the vertex lock so the caller can fire it OUTSIDE the lock (the seam may re-enter the graph). Empty ⇒ declared field writes just store.

inline vertex_t *parent() const noexcept

The owning parent node (nullptr only at the graph root).

Note

Immutable once linked — safe to walk without any lock.

inline bool registered() const noexcept

True once a registration filled this node; false for a placeholder — a structural intermediate level that find / read_children must not surface (matching the flat-map behavior where missing intermediates did not exist).

Note

Read/written under the graph’s map lock.

inline bool enumerable_member() const noexcept

True iff this vertex is a :children[] MEMBER of its parent — registered and not enumeration-hidden (RFC-0014 §3, S4).

The enumeration-hide seam RFC-0014 §3 says “the implementation must add”: the RFC-0014 creator endpoint <net_root>/<module>/conn is a real, registered, addressable vertex — find resolves it, a SPEC/NAME write executes on it, conn:schema reads it — but it is NOT one of the module’s connections, and a topology walker that treats every :children[] member as a link descends into a control vertex with no peer behind it (the concrete bug the TypeScript client had to work around in #1302).

Deliberately NARROWER than “invisible”: hiding is a member-listing property only. registered() — which governs find, retirement walks, the branch/leaf fork (has_registered_child) and the owner-side for_each_vertex census — is untouched, because RFC-0014 §6 makes read <module>/conn:schema the sanctioned creatability probe: the endpoint has to stay addressable precisely BECAUSE it is unlisted.

Note

Read/written under the graph’s map lock, like registered.

inline std::uint32_t retire_gen() const noexcept

This vertex’s retirement generation (ADR-0062).

Bumped every time retirement re-virginizes this object, so a holder of a CACHED resolution can tell “the same vertex” from “the same address, a new occupant”. A handle alone cannot: the vertex map is pinned and insert-only, so a stale handle stays usable and would silently address the revived path’s new owner.

inline void refresh_registered_child() noexcept

Recompute flag_t::REGISTERED_CHILD. Unique-map-lock callers only.

inline bool has_registered_child() const noexcept

True iff at least one DIRECT child is registered — the branch/leaf fork of the plain read surface, answered without taking the graph’s map lock (#652).

This used to be graph_t::has_registered_child, which took map_mutex_ shared and walked the child list to compute the same predicate. That lock was the single largest term on the read path and, being process-wide, it capped every read in the process at roughly 20 M/s no matter how many cores or how disjoint the vertices: short-circuit it and twenty-four readers on distinct vertices go from 19.7 to 165.3 M ops/s. A blocking lock does not collapse the way a spin lock does — it plateaus — which is exactly why this was invisible for so long: a flat aggregate reads like “scales fine” until you notice that flat across a 24x thread range means each thread is 24x slower.

The counter is mutated only by fill and mark_unregistered, both of which run under the graph’s UNIQUE map lock, so mutations are already serialized; the atomic is what makes the read race-free. A reader concurrent with a registration may observe either side of it — exactly as it could when the fork took a shared lock, since the API orders a read against a concurrent register_vertex no more strongly than this. The composed branch read re-acquires the map lock for its own walk, so the ordering that walk depends on is not this counter’s to provide. One observable follows from that gap: a read racing the retirement of the LAST registered child may see the bit set here and then find no registered child under the walk’s lock, composing the root alone — a POINT with zero child records. That reply is LEGAL, byte-identical to the fully READ-ACL-pruned reply RFC-0016 §B produces deterministically (erratum 2026-08-13, #1030) — a transient in frequency, not a new shape.

inline vertex_t *child_by_record(std::span<const std::byte> record) const noexcept

The child whose own NAME record equals record byte-for-byte, or nullptr — one level of the O(segments) resolution walk (ADR-0057).

Note

Called under the graph’s map lock (shared suffices).

template<typename F>
inline void for_each_child(F &&f) const

Run f over every child (placeholders included), in sorted name-record order — member enumeration and the RFC-0005 subtree-counter walks.

Note

Called under the graph’s map lock (shared suffices); f must not mutate the tree.

template<typename F>
inline void for_each_descendant(F &&f)

Run f over every DESCENDANT (this vertex excluded), pre-order, iteratively.

The subtree counterpart of for_each_child, and the reason it exists is stack safety: the four subtree walks in graph.cpp were self-recursion, one frame per graph level at 32–208 B a level, and graph depth is a vertex’s path segment count — which nothing on the wire path bounds. kMaxSegments is enforced only in path_t::parse, the local string builder; graph_t::ensure_vertex takes raw key bytes and counts nothing, so a peer could already create a vertex deep enough to overflow the stack of a walk it then triggers (:subscribers[], RETIRE, :acl). See #690.

Descends with no auxiliary storage at all — no explicit stack, so nothing to allocate and nothing to fail. It ascends via the parent link and re-finds its position among its siblings by binary search on its own NAME record, which the sorted list already supports (the same lower_bound child_by_record uses). That costs O(log children) per ascent instead of the O(1) an explicit stack would give, and buys back an error channel the two void callers could not have carried without a signature change.

Note

Same contract as for_each_child — called under the graph’s map lock, and f MUST NOT mutate the tree — the walk holds no snapshot and re-reads sorted on every ascent, so an insertion mid-walk would move the position it is about to resume from.

inline void note_write()

Record a Handler-role write: bump the write sequence and wake awaiters (the vertex stores no value — the user handler consumed it).

inline std::shared_ptr<const rope_t> read_stored() const

The stored last-known-value (lock-free; null ⇒ never assigned / Handler role).

inline bool wait_for_change(std::uint64_t seq0, std::chrono::nanoseconds timeout)

Block until the write sequence moves past seq0 or timeout elapses.

Parameters:
  • seq0 – The current_seq snapshot the caller waits to see surpassed.

  • timeout – The maximum wait.

Returns:

true iff a change was observed (write_seq_ != seq0); false on timeout.

inline std::uint64_t current_seq() const

The current write sequence (bumped per assign — the await predicate base).

inline void mark_flushed()

Advance the STREAM drain cursor to “now” WITHOUT draining (RFC-0008 §E): an eager delivery already flushed the ring, so a later sweep must not re-deliver.

inline std::size_t drain_unflushed(std::vector<std::shared_ptr<const rope_t>> &out, std::uint64_t *gap_before = nullptr)

Drain the STREAM ring entries appended since the last flush, in order — a queue, not a coalesce (RFC-0008 §E) — and advance the drain cursor.

Snapshots under the lock into out (caller storage; overwritten); the caller delivers OUTSIDE the lock. Entries trimmed out of the keep-last ring before this drain are lost (bounded history). The snapshot growth is NOTHROW (#477): on OOM the drain returns 0 WITHOUT advancing the cursor, so the entries re-drain on the next covering flush — deferred, never lost, never an abort.

Note

#981 residual: “never an abort” holds where the growth THROWS. Under -fno-exceptions tr::detail::try_reserve degrades to probe-then-commit — the probe block is freed before reserve takes one and a context switch in that window makes the reserve abort() the node (#850). The snapshot cannot take the ADR-0065 tr::mem::block_array_t seam: its element is a std::shared_ptr, which the seam’s memcpy relocation would tear, and out is caller storage of a type this signature fixes.

Note

Counts ring APPENDS, never a write_seq_ delta (#925): that sequence is the await/readiness cursor and bumps on a SHED append too, so the surplus would re-take an ALREADY-FLUSHED entry — a drain removes nothing from the ring.

Parameters:
  • out – Caller storage the drained entries are assigned into (overwritten).

  • gap_before – Optional out: shed points observed on this ring since the previous drain — the in-order tr::flow::address_shift_gap signal of RFC-0025 §4.4/§4.5. Non-zero means entries the consumer would have seen are MISSING immediately before this batch. Written unconditionally when non-null, including on the zero-drain returns, so a consumer that polls a quiet ring still learns about a shed.

Returns:

The number of entries drained (0 ⇒ nothing appended since the last flush, or the snapshot could not be allocated — retry on the next flush).

inline std::vector<rope_t> history_snapshot()

The STREAM ring contents, oldest first — each entry a rope clone (refcount bumps, no byte copy).

inline std::size_t ring_reserved_bytes() const

Total bytes this receiver currently holds RESERVED against its injected ring source — the byte bound’s observable (RFC-0025 §4.6.1 clause 3). Zero on a vertex that has never admitted an entry.

inline std::uint64_t ring_gap_count() const

Shed points on this receiver’s ring since registration — the cumulative tr::flow::address_shift_gap census (RFC-0025 §4.4: a shed with no accounting is non-conforming).

inline std::size_t add_edge(subscriber_t s, edge_latch_t *latch = nullptr)

Append a subscription edge; atomically snapshot the transient-local durability latch when latch is non-null.

Under ONE lock hold: the slot is appended, and — iff THIS subscriber requested durability (policy.durability_request(), RFC-0022 §3.A) and the vertex already holds an LKV — the value plus the new edge’s dispatch view are snapshotted into latch, so a concurrent clear_edge can never slip between append and latch. The caller dispatches the latch OUTSIDE the lock (RFC-0004 §D / ADR-0049).

The predicate is the SUBSCRIBER’s, not the vertex’s: before RFC-0022 one settings.durability flag latched for every subscriber of a vertex, including the ones that never asked.

An INACTIVE slot is REUSED before the list grows (RFC-0009 §D.2: “a cleared

slot MAY be reused by a later append”) — the reclamation half of eviction: a churning link (unsubscribe / peer departure, then re-subscribe) reoccupies its freed slots instead of growing

subs_ without bound. Slot indices of ACTIVE edges are never renumbered (§D.2 stability); only a slot already cleared can come to mean a new edge. The edge is PUBLISHED under the same lock hold, in the position the slot append itself holds (#635): the caller’s own_subs_ seq_cst bump still precedes this whole verb, so ADR-0049’s Dekker pairing against a skipping publisher is byte-for-byte the one #708 landed. Displaced arrays are scanned AFTER the lock is dropped, on this thread.

Returns:

The occupied slot’s index (the :subscribers[N] slot number), or kNoSlot when the edge array could not be allocated — nothing was admitted and the previously published array is untouched, so the vertex is unchanged.

inline bool clear_edge(std::size_t idx, void **retired_ctx = nullptr)

Deactivate the edge slot idx (unsubscribe — a cleared :subscribers[N]).

Parameters:

retired_ctx – Optional out-parameter receiving the slot’s callback_ctx — the one leg of a dispatch snapshot the library holds no owning copy of, so ADR-0080’s reclamation seam needs it back to hand to a release hook. Read UNDER the lock, before the shell displaces the slot, because after that the pair is gone. Written only on the true return; left untouched when nothing was cleared.

Returns:

true iff the slot existed and was active (the caller then adjusts the RFC-0005 listener bookkeeping).

inline edge_replace_t replace_edge(std::size_t idx, subscriber_t s, edge_latch_t *latch = nullptr)

Replace the edge occupying slot idx (RFC-0009 §D.1), snapshotting the transient-local durability latch under the SAME single lock hold as add_edge.

§D.1 makes an indexed :subscribers[N] write of a SUBSCRIBER replace that slot rather than destroy it. The latch is taken here, not by the caller, for the reason add_edge gives: a concurrent clear_edge must not be able to slip between the write and the snapshot. The caller dispatches OUTSIDE the lock.

Move-assigning the slot reclaims the displaced edge’s source_view segment pin and cold remote half in place, exactly as clear_edge does — a replace must not leak the frame segment the old edge pinned.

This never grows . An out-of-range idx is refused rather than back-filled with inactive shells: the index arrives off the wire, so growing on demand would let a peer allocate an arbitrary number of slots with a single :subscribers[65535] write. Slot indices are stable per §D.2, so a slot that does not exist yet is not addressable.

Parameters:
  • idx – The :subscribers[N] slot number.

  • s – The replacing edge.

  • latch – Optional durability latch; snapshotted iff the REPLACING subscriber requested durability (RFC-0022 §3.A) and the vertex holds an LKV.

Returns:

Which case applied — see edge_replace_t.

Deactivate AND reclaim every active subscriber edge stored against the link link — the per-vertex half of peer-departure eviction (RFC-0009 §D, extended to link teardown).

Matches each active slot on the link it was ADMITTED over: the cold half’s subscriber_remote_t::link when it carries one, and otherwise its subscriber_remote_t::caller — the two spellings the two admission doors leave behind for the SAME fact. subscribe_wire (the SUBSCRIBE op and the wire :subscribers[] append) stores both; graph_t::field_write’s :subscribers[] / :subscribers[N] arms store ONLY the context, because those edges deliver to a LOCAL target and have no return route to send over. Matching link alone therefore left a field-write-admitted edge permanently un-evictable — active, counted, and still fanning out to its target under a gate context whose session had departed (#943). ADR-0018 defines that context as this node’s NAME for the inbound link a remote FWD arrived on, i.e. the same name space link is spelled in, so the fallback compares like with like. A local edge still never matches a real link name: a local door passes the EMPTY context and stores no cold half at all (the one exception, parse_subscriber_tlv’s delivery_compact opt-in, leaves both spellings empty). An EMPTY link matches nothing at all and returns 0 — a link with no name never subscribed, and without that rule the empty key compared equal to exactly those empty spellings and reclaimed every local delivery_compact edge on the vertex (#1056). Unlike clear_edge, a matched slot is RECLAIMED, not just flagged: the stored SUBSCRIBER view, the return-route refcount pin, the target key, and the whole subscriber_remote_t block are released in place (the slot shell stays — §D.2 index stability — and add_edge reuses it). An in-flight delivery is unaffected: its edge_view_t snapshot HOLDS the target key and the whole subscriber_remote_t by refcount (ADR-0041 §2, #1448), so releasing the slot’s pin here never dangles a dispatch — the record it reads outlives this eviction by construction.

Returns:

The number of edges evicted (the caller unwinds exactly this many from the RFC-0005 listener bookkeeping).

inline std::size_t evict_route_edges(std::string_view link, std::span<const std::byte> route, bool bound_echo = false)

Reclaim the remote edges whose delivery link AND stored return route both match — the per-vertex half of graph_t::evict_route_edges (#1223 step 5).

The narrow sibling of evict_link_edges — where that one reclaims EVERY edge a departed link admitted, this one reclaims exactly the edge(s) whose next hop refused the stored route with an addressed tr::path::invalid (RFC-0020) — the one wire observation a producer gets about a route whose terminal session departed. Matching link alone would evict every edge sharing the mount; matching the route alone would let any link speak for another’s edges — both keys are required, and the route compare is BYTE-equal on the stored PATH TLV (the same bytes deliver_remote emits as the delivery dst, which are the bytes the refusing hop echoes back — see reject_bus_name_hop’s swap). Only subscribe_wire-door edges qualify: a field-write edge stores no route, and route never compares equal to its empty view. An EMPTY link or route matches nothing, as in evict_link_edges (#1056).

Parameters:
  • link – This node’s NAME for the link the refusal arrived on (== the edge’s delivery link).

  • route – The refused route — the whole TLV bytes echoed by the rejecting hop: a canonical PATH, or (RFC-0024 §7.1 amendment 1) the bound PATH_REF a reverse-list delivery was refused as.

  • bound_echo – True ⇔ route is the PATH_REF form — the caller classified the echo’s type byte (this header stays wire-type-agnostic), and the match runs against the stored reverse list’s emitted suffix instead of the canonical return route.

Returns:

The number of edges evicted (the caller unwinds exactly this many from the RFC-0005 listener bookkeeping).

inline std::size_t snapshot_edges(edge_snapshot_t &inline_buf, std::vector<edge_view_t> &overflow, snapshot_drops_t &drops)

Snapshot every ACTIVE edge’s dispatch view into caller storage — the snapshot-under-pin half of the snapshot/dispatch-after-release discipline.

Small fan-out (the common case, ≤ kInlineFanout) placement-constructs into inline_buf — no heap allocation AND no dead stack zeroing per publish; a larger subscriber list reserves overflow once and fills it instead (then overflow is non-empty and holds ALL views).

The ONE way this can come back short is NOTHROW (#477 — this runs on the writer thread’s fan-out, where a bad_alloc is an abort() under -fno-exceptions): an unreservable overflow degrades the snapshot to the first kInlineFanout views in inline_buf, and the rest of this delivery is dropped. It is TALLIED into drops (snapshot_drops_t) so the caller can report it; it is not silent (#896). The per-edge copy itself cannot fail at all since #1448 — it is two pointer copies and two refcounts on EVERY edge shape, remote included — so the whole snapshot reaches an allocator only for that one overflow reservation. NO LOCK (#635). The source is the vertex’s PUBLISHED, immutable-after-publish edge array, read under a bounded per-participant EDGE PIN (edge_pin.hpp) whose scope is this copy loop and nothing else — released before the caller’s first dispatch_edge, so a subscriber callback that re-enters the graph always finds this thread’s cell empty (pin_t asserts it). What this deletes is the stripe mutex, which serialised the publishes of every vertex that merely HASHED to the same stripe: measured at ×16.6 with NEGATIVE scaling past four threads. What it does not add is any shared-cacheline RMW — the announcement is a seq_cst store to this thread’s own isolated cell, which is the whole reason a refcounted published array was rejected instead.

Fallback: a thread that cannot claim a pin (more publishers than kEdgePinSlots) copies the CURRENT array under the stripe mutex — safe because displacing an array requires that same lock. Correctness never depends on the constant; only scaling does.

Parameters:
  • inline_buf – The caller’s raw stack buffer (cleared on entry).

  • overflow – The heap fallback for large fan-out (cleared on entry).

  • drops – Out: what this snapshot SHED (snapshot_drops_t), zeroed on entry. By reference, not optional — a caller that may not see the shed count is the #896 defect itself.

Returns:

The number of views snapshotted (into whichever buffer was used).

inline std::optional<view_t> edge_source(std::size_t idx)

The stored SUBSCRIBER TLV view of the active slot idx (a :subscribers[N] read) — a refcount clone, no byte copy; nullopt for a missing / inactive / TLV-less (in-process sugar) slot.

inline std::vector<view_t> edge_sources()

Every active slot’s stored SUBSCRIBER view, in slot order (the :subscribers[] array read) — each a refcount clone.

inline value_handlers_t *revert_to_placeholder()

Restore this vertex to the state an unregistered PLACEHOLDER carries — the unregistered carries no state invariant retirement re-establishes (RFC-0009 §B.6). Clears everything a installs plus everything it leaves behind, so a later revive of this address inherits nothing of the retired owner: the value seam (swap-and-park, never freed — a lock-free reader may still hold the old pointer), the stored value and history, the :acl (own ACEs + the cached merge), the app-field table, the storage policy, the role, and the delivery mode. Survives by design: write_seq_ (monotonic per address; a reset would regress the readiness cursors), listeners_above_ (counts ANCESTOR subscribers, which retiring THIS vertex never touched — the graph adjusts it for cleared descendant edges), and the allocation / name / links (ADR-0057 insert-only — emptied, never freed or detached).

Note

registered_ is NOT touched here — it is map-lock state the graph flips. The caller MUST hold the graph map lock. This RETURNS the swapped-out value-seam block (or nullptr) rather than freeing it: a lock-free reader may still hold the old pointer, so the graph parks it and the embedder frees the park through graph_t::collect() (#576). The per-vertex stripe lock is taken internally.

Returns:

the detached seam block to park, or nullptr if this vertex had none.

inline void set_acl(std::vector<ace_t> aces)

Store this vertex’s :acl as typed ACEs — the ONLY stored ACL state (#907).

Storing replaces, and marks the ACL PRESENT: an empty list is the sanctioned clear-enforcement write (⇒ no restrictions) and still reads back as an empty ACL, not as the NOT_FOUND of a vertex that never had one. Takes no raw bytes, because there is no second copy to fall out of step with the list evaluation walks — an :acl read re-encodes from here.

template<typename F>
inline auto with_acl(F &&f) -> decltype(f(false, std::declval<const std::vector<ace_t>&>()))

Run f over this vertex’s whole :acl state — the presence bit and the parsed ACE list, read together under ONE hold — the read-back accessor (#907).

The caller re-encodes the list it is handed (graph::encode_acl lives a layer up and cannot be named from here), which is what makes an :acl read canonical: it serves a projection of the SAME list acl_allows evaluates, so the two can no longer disagree. Presence and list travel together because a clear that landed between two accessors would otherwise be served as an ACL that no longer exists.

f must not re-enter this vertex — the lock is held.

Returns:

Whatever f returns.

template<typename F>
inline auto with_aces(F &&f) -> decltype(f(std::declval<const std::vector<ace_t>&>()))

Run f over this vertex’s parsed ACE list under the vertex lock — the zero-copy evaluation accessor (graph_t::acl_allows hands the list to the pure ADR-0050 policy without snapshotting subject bytes per gated op).

f must not re-enter this vertex (the lock is held) — it is a pure evaluation over the list, per the ADR-0050 policy contract (no locks/clock/graph inside).

Returns:

Whatever f returns.

inline void mark_acl_cache_dirty() noexcept

Mark this vertex’s cached effective-ACE merge stale (ADR-0050/0078).

Raised by the graph on every :acl write for the WRITTEN vertex’s whole subtree (subtree-precise invalidation via the ADR-0057 child links — wiring-frequency); set_acl raises it for the written vertex itself. The next with_effective_aces on a marked vertex rebuilds lazily.

Note

Lock-free (one uncontended CAS) — callable under the graph’s map lock during the subtree walk without touching any vertex mutex.

template<typename Rebuild, typename Eval>
inline auto with_effective_aces(Rebuild &&rebuild, Eval &&eval) -> decltype(eval(std::declval<const std::vector<ace_t>&>()))

Evaluate against this vertex’s cached effective-ACE merge, rebuilding it first iff it is stale — the ADR-0050 cached-merge verb.

Staleness is ONE bit of ONE word (ADR-0078): vertex_ext_t::acl_gen is odd. When it is, that odd value and this vertex’s own parsed ACEs are SNAPSHOTTED and rebuild runs with the stripe lock RELEASED (#361 §2) — the graph’s rebuild walks the immutable parent chain taking each ancestor’s with_aces one stripe lock at a time, never nested, so an ancestor sharing this vertex’s stripe cannot self-deadlock. Back under the lock the rebuilder publishes by CAS-ing that snapshot to snapshot + 1 (even), then eval runs.

Race resolution (rebuild vs concurrent :acl write): every invalidator — set_acl, the placeholder revert, the subtree mark an ancestor :acl write fans out (mark_acl_cache_dirty) — advances that one counter via invalidate_acl_cache after publishing its ACEs, lock-free, and does NOTHING else. The recheck and the publish are therefore the SAME atomic operation, so an invalidation landing anywhere in the rebuild defeats the CAS. That is the whole of the coherence argument, and it is what the retired {acl_gen, acl_cache_dirty} pair could not give: there they were two ops, and a mark landing between them was overwritten by dirty = false, pinning a stale merge as clean FOREVER (#880) — a revoked policy still enforced. A failed CAS also discards a merged that may be TORN across the write rather than answering from it. The one premise left is that the counter does not WRAP onto a stale-but-even value (vertex_ext_t::acl_gen).

Parameters:
  • rebuildstd::vector<ace_t>(const std::vector<ace_t>& own) — the fresh merge over a snapshot of this vertex’s own ACEs; runs UNLOCKED (it may take other vertices’ stripes freely).

  • eval – Pure evaluation over the cached merge. A BARE descendant evaluates the merge’s kAceInherit subsequence, which eval selects with effective_acl_t::allows’s required_flags rather than receiving a second, pre-projected list — filtering in place is order-identical and costs no storage. ADR-0050 policy contract: no locks/clock/graph inside.

Returns:

Whatever eval returns.

inline void set_app_fields(std::vector<app_field_t> table)

Install (or replace) the field descriptor table — the OWNER naming the holes in the closed ENOTTY default (RFC-0010 §A.2), one more store-verbatim verb on this seam (the set_acl pattern).

Replacement takes effect atomically with respect to concurrent field operations on this vertex (one lock hold). An empty table uninstalls — the vertex reverts to the closed surface, including the pre-RFC synthesized :schema shape — and, on a vertex that never had an extension block, allocates nothing (#361 §1: a leaf with no app fields pays nothing).

inline void set_app_fields_static(borrowed_fields_t table)

Install a BORROWED descriptor table (ADR-0058): the slots view the caller’s table storage directly — zero declaration RAM. The array AND the name / descriptor bytes it points at MUST outlive the vertex (static/flash storage); borrowed_fields_t is what constrains the argument’s shape to match. Declaration only; values are written later via the field-write surface. Same uninstall-on-empty and allocate-nothing-on-empty-leaf semantics as set_app_fields.

inline std::optional<app_access_t> app_field_access(std::string_view name)

The declared access of the app field name (nullopt ⇒ undeclared — the graph’s SCHEMA_NOT_FOUND).

inline bool app_field_store(std::string_view name, std::span<const std::byte> bytes)

Store bytes verbatim into the DECLARED app field name (RFC-0010 §D — bytes in, bytes out; no dtype/range validation, the descriptor is consumer self-description).

Returns:

false iff name is not declared (e.g. a concurrent table replacement removed it between the caller’s gate and this store).

inline app_read_t app_field_get(std::string_view name, std::vector<std::byte> &out)

Read the app field name into out (the stored TLV bytes, verbatim); out is written only on app_read_t::OK.

inline std::vector<app_field_t> app_fields_snapshot()

A consistent copy of the whole descriptor table, in install order — the container-read / :schema snapshot (control-plane cold; empty ⇒ no table installed).

inline void set_history_depth(std::uint32_t keep)

Set the STREAM ring depth (RFC-0022 §3.C) — owner-side, never over the wire.

Allocates the extension block if this vertex has none (a STREAM vertex always has one already). Taken under the vertex mutex, which is the same lock the ring append re-reads it under, so a depth change and a concurrent store cannot interleave halfway.

Parameters:

keep – Entries to retain; 0 is normalised to 1 by the ring trim.

inline std::uint32_t history_depth() const noexcept

The STREAM ring depth this vertex retains (1 when never declared).

inline void set_ring_source(tr::mem::block_source_t *src, bool reliable)

Bind this RECEIVING vertex’s own ring source and §4.4 pressure arm — the seam of RFC-0025 §4.6.1 clause 3, owner-side and with no wire surface.

Sited on vertex_ext_t’s lazily-allocated ring block, never on vertex_t itself: a STREAM identity already allocates the extension block, and the whole of the ring’s state hangs off the one lazy pointer that block already held — so sizeof(vertex_t) does not move, sizeof(vertex_ext_t) does not move either, and a vertex that never receives pays nothing. The #1285 ratchet and the RAM census are both untouched.

REBINDING DRAINS. Reservations are released to the source that served them (sized reclaim), so a rebind first hands every queued entry’s block back to the OLD source and empties the ring; the queue restarts on the new budget. Wiring-time by intent — the “configure before frames flow” contract set_history_depth and set_delivery_mode already carry.

Parameters:
  • src – The source this receiver’s admissions are charged against. nullptr unbinds, so the next admission re-resolves the graph-level default.

  • reliable – The §4.4 arm: false (default) best-effort — shed oldest, account the loss, raise a gap; true reliable — refuse the admission and answer the local producer BACKPRESSURE, shedding nothing.

inline tr::mem::block_source_t *ring_source() const noexcept

This receiver’s bound ring source, or nullptr while it still draws the graph-level default (nothing admitted and nothing declared).

inline void set_pin_payload_ratio(std::uint32_t k)

Set the RFC-0022 §3.D pin amplification ratio K (ADR-0042 §3) — owner-side, never over the wire. 0 (tr::graph::kPinNever) never pins.

Published under the vertex mutex; the write-path reader (pin_payload_ratio) takes no lock, because a ratio changing under a concurrent write only decides WHICH correct store shape that write takes.

inline delivery_mode_t delivery_mode() const noexcept

How this vertex participates in an ANCESTOR’s propagate sweep (RFC-0008 §C).

Relaxed, and deliberately racy against a concurrent set_delivery_mode: the assign path reads it lock-free as a FAST PATH only (graph_t::mark_pending), and whichever of the two values it observes there, the decision that actually places the vertex in a sweep set is re-taken under the graph’s sweep lock. ATOMIC because set_delivery_mode may run concurrently on another thread (#895) while this read holds NO lock — which is the whole reason it needs to be atomic, and what distinguishes it from the other plain members of the same byte group: registered_ is map-lock state on both sides (see mark_unregistered), so a plain bool is correct there.

inline void set_delivery_mode(delivery_mode_t mode) noexcept

Set the propagation policy — wiring-time, via graph_t::set_delivery_mode (which also maintains the sweep’s UNCONDITIONAL membership, and holds its sweep lock across this store so the two stay one decision).

inline bool has_own_aces() const noexcept

True iff this vertex has its OWN parsed ACEs (#361 §3) — the lock-free predicate of the graph’s nearest-bearing-ancestor walk. Relaxed read: a racing :acl write is observed by the next gated op at worst, the same window the dirty-flag protocol already tolerates.

inline std::uint32_t own_subs() const noexcept

This vertex’s own active-slot count (what a subtree walk sums).

inline std::uint32_t own_subs_ordered() const noexcept

The same count under seq_cst — the SUBSCRIBE half of a Dekker pair, and the only read that may be used to SKIP a DELIVERY (#635, #1140).

A relaxed read is fine for every consumer that only decides how much work to do (own_subs above). It is NOT fine for one that decides whether to deliver at all: a publisher that skips snapshot_edges on a zero count must be ordered against a subscribe that is concurrently taking ADR-0049’s durability latch, or the new subscriber gets the latch’s OLD value and never sees the publish that raced it.

“Skip a delivery” covers both halves of the write path, EAGER and DEFERRED. #635 fixed the eager one (graph_t::fan_out’s snapshot skip); #1140 fixed the deferred one (graph_t::mark_pending, where a skipped mark leaves the vertex in no sweep set, so the next covering propagate delivers it nowhere). The distinction between skipping a fan-out and skipping a mark is bookkeeping — the lost delivery is the same, so the same read is required. Only the OWN half; the ancestor count keeps its relaxed load, see listeners_above.

The pairing is the one store already documents for waiters. PUBLISHER: store the LKV, THEN load this count. SUBSCRIBER: bump this count, THEN load the LKV into the latch. Both sides seq_cst, so they share one total order: a publisher that reads zero is ordered before the subscriber’s bump, hence before the subscriber’s latch load — so the latch carries the value the skipped fan-out would have delivered. The other interleaving (count already bumped, slot not yet appended) costs one pointless lock acquisition that snapshots nothing, never a lost delivery.

Both halves take kDeliverySkipOrder, which a weakly-ordered target (tr::graph::kWeaklyOrdered) static_asserts is still seq_cst — so the argument above is a build failure when it stops holding, not only a paragraph (#1143).

inline void bump_own_subs(std::int32_t delta) noexcept

Adjust the own active-slot count by delta (subscribe/unsubscribe).

Note

seq_cst, not relaxed: this is the subscriber’s half of the pair own_subs_ordered describes. Subscribe is control-plane-cold, so the stronger order costs nothing that is measured.

inline std::uint32_t listeners_above() const noexcept

The active subscriber slots on strict ancestors — the one relaxed load the write hot path pays before deciding whether to walk ancestors at all.

Note

Relaxed BY RULING even where it gates a SKIP, so there is no _ordered twin (#854, measured and REFUTED): a stale zero here is indistinguishable from the write linearizing before the racing subtree subscribe, because ADR-0049’s latch snapshots the subscribed ANCESTOR’s own LKV (add_edge) and never a descendant’s — so unlike own_subs_ordered’s near-axis pair there is no forbidden observation to exclude, and the seq_cst candidate doubled the idle write’s rv32 fence count to exclude nothing.

inline void bump_listeners_above(std::int32_t delta) noexcept

Adjust the ancestor-listener count by delta (an ancestor’s edge came/went).

inline void init_listeners_above(std::uint32_t count) noexcept

Seed the ancestor-listener count at creation (the newborn’s O(depth) sum).

Public Static Attributes

static constexpr std::size_t kInlineFanout = edge_snapshot_t::kCapacity

The no-heap small-fan-out snapshot width (snapshot_edges buffer size).

static constexpr std::size_t kNoSlot = static_cast<std::size_t>(-1)

What add_edge answers when the edge could NOT be admitted — the injected resource is exhausted (#477: the writer soft-fails by value; a bad_alloc under -fno-exceptions would be an abort(), and admission is reachable from a peer’s bytes since RFC-0014).

A caller that sees it must NOT count a listener: nothing was appended and nothing was published, so the vertex is exactly as it was.

struct snapshot_drops_t

What a snapshot DECLINED to hand back: the deliveries a vertex shed before the graph could dispatch them (#896).

snapshot_edges is allowed to come back short, and the way it can is a specified drop rather than an abort (#477). A drop nobody counts, though, is indistinguishable from a delivery that never had to happen — which is how a whole fan-out could be shed under memory pressure while graph_t::delivery_drops(), the one observable, read zero. vertex_t owns no counters (it is the storage layer, not the instrumentation layer): it reports the tally by reference and graph_t::fan_out folds it into the graph’s per-cause counters at the frame that owns them.

There used to be a second cause, and #1448 deleted the failure, not the report. A per-edge out_of_memory counted the edges whose owning link / caller copies could not be allocated. edge_view_t no longer copies them — it takes a refcount share of the immutable cold half — so the per-edge snapshot reaches no allocator on any edge shape and cannot fail. What remains is the capacity degrade: a fan-out wider than the inline snapshot, on a heap that would not lend it a buffer. The OUT_OF_MEMORY delivery cause is untouched and still counted from the legs that can still hit it (graph_t::dispatch_edge_target’s rope clone, the store legs).

Public Functions

inline bool any() const noexcept

Did this snapshot shed anything? The ONE test a clean fan-out pays.

Public Members

std::uint32_t truncated = 0

Edges past the inline prefix, abandoned because the overflow buffer for a wide fan-out could not be reserved — the capacity degrade.

struct store_drops_t

What a store SHED under allocation pressure — reported BY REFERENCE, never counted here (#1003).

The same division of labour snapshot_drops_t states for the fan-out plane, for the same reason: vertex_t is the storage layer and owns no counters, so it reports the tally and graph_t folds it through the single exhaustive counting door. A shed the storage layer knows about and the graph never hears of is exactly the defect — a whole STREAM fan-out was abandoned under memory pressure while graph_t::delivery_drops(), the one observable, read zero.

The width is the CALLER’s call, not this struct’s: whether a shed append cost a delivery depends on whether the ring drain was the delivery (it is for the write and sweep paths; it is not for a branch notify, which fans the slice out eagerly and then flushes the cursor). See graph_t::count_store_drops.

Public Functions

inline bool any() const noexcept

Did this store shed anything? The ONE test a clean write pays.

Public Members

bool ring_append = false

The RECEIVER’s STREAM ring could not admit: its injected source declined the reservation and the ring had nothing left to shed, so the entry never entered the ring. The LKV publish ABOVE it still landed — the write succeeds (RFC-0008 §E, bounded-lossy history), and what is lost is the delivery a later drain would have made.

std::uint64_t ring_shed = 0

How many queued entries the best-effort arm SHED to make room (RFC-0025 §4.4: “shed the oldest, whole, never partial”). Each one is both a lost delivery and a tr::flow::address_shift_gap point; silence here is the one behaviour the RFC forbids.

struct edge_block_t

A vertex’s edge state, allocated on FIRST subscribe and freed with the vertex.

Pay-for-what-you-use, the #361 §1 discipline: an edgeless vertex — the overwhelming majority on an MCU node — owns a single null pointer, where it used to own an empty std::vector (24 B on a host, 12 on rv32). The block itself is never displaced or reclaimed, only its published arrays are, which is why snapshot_edges may load it with a plain acquire and no pin at all.

slots is the MASTER: the :subscribers[N] slot table, index-stable per RFC-0009 §D.2, mutated only under the vertex stripe mutex exactly as it was before #635. pub is the dispatch-side projection of it that publishers read without any lock.

Public Functions

inline ~edge_block_t()

The teardown flush: free the published array AND everything still parked.

The contract this states, in the same shape ADR-0069 §6 states for the LKV domain: the graph — and therefore every vertex — must OUTLIVE the threads that published through it. A thread still inside snapshot_edges when its vertex is destroyed is a use-after-free with or without this mechanism, and the ASan/LSan legs exercise the flush on the joined-threads side of that line.

Public Members

std::vector<subscriber_t> slots

The master slot table (stripe-locked).

std::atomic<edge_pub_t*> pub = {nullptr}

The published array (null ⇒ no edges).

std::atomic<edge_pub_t*> retired = {nullptr}

Displaced arrays awaiting a scan.

The receiver-side STREAM ring (RFC-0025 §4.6.1): one lazily-allocated block per receiving vertex, holding the queued entries, the block_source_t their admissions are charged against, the §4.4 pressure arm and the gap census. Each entry carries the reservation it was admitted under — admission, not placement: the payload stays where the publish put it.

struct ring_state_t

A receiving vertex’s whole STREAM-ring state, LAZILY allocated as one block (RFC-0025 §4.6.1 clause 3) — the entries, the injected source they are charged against, the pressure arm, and the gap census.

Grouped behind ONE pointer on purpose, and that is the difference between a seam every ext-bearing vertex pays for and one only the receivers do. vertex_ext_t already held a lazy pointer for the ring’s entries; hanging the source, the arm and the two counters off that same pointer keeps sizeof(vertex_ext_t) EXACTLY where it was, so a vertex with app fields, an :acl or a handler — which allocates the cold block for reasons of its own and will never admit a stream entry — pays zero additional bytes. The RAM census (vertex_app5, vertex_app5_static, reg_escape) is the gate that says so, and it caught the four-loose-members spelling of this at +32 B.

Public Functions

inline void release_all() noexcept

Release every held reservation and empty the ring — the ONE place the charge/release pairing is closed, shared by the destructor, the placeholder revert and graph_t::set_ring_source’s rebind. Idempotent.

inline ~ring_state_t()

Hand every reservation back before the block dies. Dropping the deque without releasing them would leak the whole ring’s byte budget on every teardown.

Public Members

std::deque<ring_entry_t> entries

The queued entries, oldest first, each holding its admission reservation.

tr::mem::block_source_t *source = nullptr

This receiver’s OWN injected source — the seam admissions are charged against.

   Null until the first admission or an explicit `graph_t::set_ring_source`, at
   which point the graph-level default (itself defaulting to
   `tr::mem::heap_source()`) is BOUND here and stays bound: the destructor and
   every trim release against this exact source, and a sized reclaim cannot be
   served by a source that did not hand the block out. Per-injection-point, never
   a shared pool — ADR-0079's amendment measured a folded source collapsing to
   0.01x of its own single-thread rate at T=24.
std::uint64_t gaps = 0

Shed points on this ring since it was created — each one a tr::flow::address_shift_gap

(RFC-0025 §4.5: “a detected discontinuity in an

ordered flow”), surfaced to the consumer IN ORDER through

vertex_t::drain_unflushed’s gap out-param and kept here for the census.

std::uint64_t gaps_drained = 0

How much of gaps a consumer has already been told about, so vertex_t::drain_unflushed reports each shed point EXACTLY ONCE, in order, at the drain that follows it.

bool reliable = false

The §4.4 pressure arm this receiver binds under: false (the default) is BEST-EFFORT — a refused admission sheds the oldest entry whole, accounts the loss and raises a gap; true is RELIABLE — the admission is refused outright and the local producer is answered BACKPRESSURE, with nothing shed and no growth past the byte bound. Declared owner-side through graph_t::set_ring_source; it is NOT a new knob on the wire — RFC-0025 §4.4 selects the arm from the subscription’s existing reliability bits, and this is where a receiver states its own.

struct ring_entry_t

One entry of a receiving vertex’s STREAM ring: the value, plus the RESERVATION it was admitted under (RFC-0025 §4.6.1 clause 3).

The reservation is the whole point, and the thing most easily misread. Admission calls tr::mem::block_source_t::try_alloc(retained_bytes) on the RECEIVING vertex’s own source and holds the block until the entry retires (trim, drain-past, revert, destruction), at which point it is released. That bounds admission, in bytes, against a budget the receiver injected.

It does NOT bound PLACEMENT. The payload never moves: value stays exactly the shared_ptr the publish handed out, in whatever allocator the value backend gave it, so the zero-copy handoff is preserved and a ring append is still a refcount bump. Physical placement migration is the later #873 family, explicitly out of scope here. A reader who assumes the ring’s bytes physically move into the injected source will be wrong, and the wrongness is expensive.

Public Members

std::shared_ptr<const rope_t> value

The published value — a refcount share of the LKV, never a byte copy.

void *token = nullptr

The admission reservation, or nullptr for an entry admitted at zero cost. Released with bytes and kAlign, the sized-reclaim contract.

std::size_t bytes = 0

The reserved width, as passed to try_alloc — required to release it.

bool gap_before = false

True iff a shed happened immediately BEFORE this entry: the in-order tr::flow::address_shift_gap marker of RFC-0025 §4.4/§4.5, so a consumer draining the ring learns where the discontinuity is, not merely that one happened.

Public Static Attributes

static constexpr std::size_t kAlign = alignof(std::max_align_t)

The alignment every reservation is taken and released at.

enum class tr::graph::role_t : std::uint8_t

A vertex’s behavioral role (docs/reference/11 §roles). Byte-wide: it packs into vertex_t’s flag byte group (#361 diet — 3 values need no int).

Values:

enumerator STORED_VALUE

Role 1: last-writer-wins; holds the last-written value.

enumerator STREAM

Role 2: the CONSUMER’s bounded history ring — a queue the RECEIVING vertex owns (RFC-0025 §4.6.1 Amendment 2: “a producer

never queues”), bounded in BYTES by that vertex’s own injected

tr::mem::block_source_t and retained to a depth declared owner-side by graph_t::set_history_depth (RFC-0022 §3.C).

enumerator HANDLER

Roles 3-7: user on_read / on_write supplies the behavior.

The node-scoped vertex index

graph_t keeps one dense, append-only vertex_t* slot per vertex ever allocated, in allocation order, with the structural root at slot 0. It exists so a bound path’s u32 index means something: the vertex tree is a Composite of non-moving unique_ptr allocations with no dense index of its own, and an element that named a tree position would have to be a path again.

It costs 4 bytes per vertex on rv32, 8 on a host — the pointer and nothing else. The index is stored chunked rather than as one growing array for exactly that reason: a geometrically-growing array holds up to twice the pointers it needs between doublings, which measured 15 B per vertex on the 512-vertex heap probe against the 8 B the cost model charges. Fixed blocks make live bytes track the vertex count instead of the last doubling, and indexing stays O(1) with elements that never move. It is not a route table — its size tracks the graph, not the traffic — and it introduces no new lifetime rule, because registration was already insert-only. A slot is appended per allocation, not per registration, which is what keeps the mapping a bijection: retirement revives a vertex by filling the same object again, and a per-registration slot would give that object two indices depending on which side of the revive a mint fell.

deref_vertex_slot is the hot side and is the whole of the check — a bounds compare and a generation compare, both under one shared map hold. It authorizes nothing; the operation that follows re-evaluates the ACL at the vertex it returns.

The generation compare is bound_generation_matches, and it refuses a saturated element outright rather than comparing it. Below the ceiling, “generations only move forward” is the whole guard — a stale element compares lower and can never come back. At the ceiling the counter stops, so a saturated element would keep matching its slot through every subsequent retire and revive, with staleness detection permanently dead for that slot. “Permanently unbindable” therefore has to be enforced on the side that honours an element, not only on the side that issues one.

vertex_slot_at is the same read the other way round — index in, generation out, in O(1) — and it exists for the FORWARDER’s mint: a hop mints for the connection vertex of the link a reply arrived on, an index it recorded once at registration, so paying a scan of the whole index per forwarded reply to re-derive an index it already holds would be the wrong shape in the wrong place. It refuses a saturated slot exactly as the scanning form does.

allows(vertex, caller, right) publishes the ACL predicate every data op already runs, for the one caller that reaches a vertex without performing a data op on it: the bound-path forwarder, whose element dereferences to a connection vertex it will egress through rather than read or write. Nothing is cached, so a revoked right takes effect on the very next frame over an already-minted binding.

vertex_slot is the mint side. It returns the index and the generation together, from one lock hold, because either alone is not a reference: read as two calls they can straddle a retire, and the pair would then name the successor tenant’s vertex while the caller believes it bound the one its operation reached. And it scans. That is deliberate rather than pending: a per-vertex index field costs 4 bytes on rv32, where sizeof(vertex_t) sits at config_t::kMaxVertexBytes32 with zero headroom, and a pointer→index side map costs strictly more than the 4 B/vertex the slot vector does. A mint happens once per binding, on a reply already being assembled.

Registration and subscription

class vertex_handle_t

A non-owning, non-null, opaque handle to a graph vertex (ADR-0056).

The caller-held result of graph_t::register_vertex / graph_t::find and the token handed back into every graph_t data op (read / write / await / assign / propagate / subscribe / history / field-write). Pointer-sized and trivially copyable, so it loads and passes exactly like the vertex_t* it replaces — identical codegen — but it exposes no operator* or raw-pointer accessor: a vertex_t is opaque L4 state, never dereferenced by callers. Constructed ONLY by graph_t (the friend), which owns the pinned, pointer-stable, insert-only vertex map — so a handle always names a live vertex for the graph’s lifetime. There is no invalid/null state; “no such vertex” is modelled by the std::optional<vertex_handle_t> graph_t::find returns.

Friends

inline friend bool operator==(vertex_handle_t a, vertex_handle_t b) noexcept

Two handles compare equal iff they name the same vertex. (!= is synthesized.)

class subscription_t

An opaque handle to ONE in-process subscription — the token graph_t::unsubscribe removes it by (ADR-0049 host-SDK sugar for the wire :subscribers[N] clear).

Returned by the callback-form graph_t::subscribe overloads. It names a producer vertex and one of that vertex’s :subscribers[] slots; the vertex is pinned for the graph’s lifetime (ADR-0057 — vertices are never freed), so the handle stays valid until it is unsubscribed. Trivially copyable and pointer-sized-plus-index — pass it by value.

Opaque the same way vertex_handle_t is, and for the same reason (ADR-0056): the pair it carries is graph_t’s state, not the caller’s. graph_t is the sole friend — the only code that can build one from a vertex and a slot, and the only code that can read either back — so a caller can neither reach the vertex_t behind a live subscription (whose slot mutators are only valid under the graph’s locks) nor forge a handle from an arbitrary pointer and index and hand it to graph_t::unsubscribe. A default-constructed handle names no subscription and unsubscribes to a NOT_FOUND no-op; operator== is the only observation a caller has.

The reclamation guarantee this handle carries (ADR-0080)

unsubscribe() retires the edge, but the fan-out path snapshots a vertex’s edges and dispatches OUTSIDE every lock, so a snapshot taken before the retirement still names the subscriber’s {fn, callback_ctx} pair — the one leg of an edge_view_t snapshot the library does not own a copy of. WHEN that pair becomes safe to free is therefore a real question, and ADR-0080 answers it with a build-time-closed, per-target policy (tr::graph::default_config_t::reclaim_policy_t), not with a runtime contract asking the caller to reason about in-flight state.

This build’s guarantee is the one stated on the bound policytr::graph::reclaim_local_t (the default), tr::graph::reclaim_strict_t or tr::graph::reclaim_qsbr_t. Under all three the library owns the tracking and SIGNALS release through the tr::graph::subscriber_release_fn_t hook of graph_t::unsubscribe(const subscription_t&, subscriber_release_fn_t): the hook runs exactly once, outside every graph lock, at that policy’s grace point. There is nothing to poll and nothing to wait on — that shape is precisely what ADR-0080 §Decision 4 rejects.

The two per-thread policies state their guarantee over ONE thread’s dispatch domain, which is the single-threaded WIDE / MCU target they are for, and run the hook on the caller’s own thread. An embedder that dispatches from several threads at once and unsubscribes from another needs a grace period spanning every thread: bind tr::graph::reclaim_qsbr_t. Its one API difference — the hook may then run on a thread other than the unsubscribe() caller, because a cross-thread grace period cannot promise otherwise without blocking — is stated on the policy itself.

Public Functions

subscription_t() = default

A handle naming no subscription — graph_t::unsubscribe answers NOT_FOUND.

Friends

inline friend bool operator==(const subscription_t &a, const subscription_t &b) noexcept

Two handles compare equal iff they name the same slot on the same producer vertex. (!= is synthesized.)

struct subscriber_t

One subscription edge (M3b).

A write to the owning vertex fans out to a target vertex (target_key — spec-faithful re-dispatch) and/or an in-process callback (sugar), per docs/reference/02 §dispatch + 04 §write fanout. An inactive slot models an unsubscribe (a cleared :subscribers[N]). The wire/gate members live in the lazily-allocated remote half (#380 §3), so the plain in-process edge costs 80 B, not 160.

Public Functions

subscriber_t() = default

A blank edge (an inert slot shell, or a door’s scratch record).

subscriber_t(const subscriber_t&) = delete

NOT copyable, exactly as it was while the cold half was a std::unique_ptr (#380 §3). The handle that replaced it IS copyable — that is the point — so the ban is stated rather than inherited: a copied slot would share a cold half that ensure_remote is then entitled to write.

subscriber_t &operator=(const subscriber_t&) = delete

NOT copy-assignable — see the copy constructor.

subscriber_t(subscriber_t&&) = default

Movable: what the slot verbs do (append, reuse, reclaim-in-place).

subscriber_t &operator=(subscriber_t&&) = default

Move-assignable: subs[idx] = std::move(...) is the reclaim.

~subscriber_t() = default

Releases this slot’s reference to the shared cold half.

inline subscriber_remote_t &ensure_remote()

The cold half, allocated on first use (admission-time only — never on a dispatch path), and MUTABLE only because the caller is still its sole holder.

The build phase of build-then-freeze (#1442). Every in-tree door fills a stack-local subscriber_t and only then hands it to vertex_t::add_edge / replace_edge, so nothing has cloned the handle yet; the assertion states that rather than trusting it, because a write reached after a publish would mutate bytes a pinned reader may be copying under no lock.

Public Members

target_key_t target_key

Canonical PATH key (null ⇒ callback-only).

target_binding_t binding = {}

Minted slot for target_key (#830).

subscriber_fn_t callback = nullptr

In-process sink fn; null ⇒ target-only (ADR-0053 §6 rope value).

void *callback_ctx = nullptr

Caller-owned context passed back to callback; must outlive every delivery.

view_t source_view = {}

The original SUBSCRIBER TLV view this slot was written from, retained zero-copy (a refcount clone of the field-write payload).

Empty for in-process callback sugar that carries no TLV (the local target sugar DOES carry one — ADR-0049 encodes through the field-write door). A :subscribers[] read ropes these slot views into the FWD{REPLY} with no byte copy (RFC-0004 §D / ADR-0035 slice 2 zero-copy reply rule). Stays HOT (outside remote) precisely because local field-write-door edges carry it.

remote_ptr_t remote

The cold wire/gate half (#380 §3) — null for the plain in-process edge; allocated by ensure_remote when a route/link/caller/compact-flag is stored (pay-for-what-you-use, ADR-0021). SHARED with every published entry that names this slot (#1442), never copied into one.

delivery_policy_t policy = {}

This subscription’s DELIVERY policy (RFC-0022 §3.A) — the packed 16 bits its SUBSCRIBER.SETTINGS{ NAME "delivery_policy" } carried, or all-zero when it carried none. HOT, not in the cold remote half: durability_request is read under the same lock hold that appends the slot, and it rides free in the padding beside active.

bool active = true

Active flag; an active edge receives every propagated value (delivery is value-agnostic — WHICH vertices a sweep propagates is the vertex’s delivery_mode_t, never a per-subscriber byte comparison).

struct subscriber_remote_t

The COLD wire/gate half of a subscription edge (#380 §3), lazily allocated and refcount-shared, immutable after admission (#1442): the in-process edge — the common MCU wiring shape (callback or local target, empty caller) — keeps subscriber_t::remote null and pays one pointer instead of ~90 B of route/link/ caller state per edge.

Build then freeze. An admission door fills one of these through subscriber_t::ensure_remote while the record is still private to its stack-local subscriber_t; the slot verb then moves it in, and from the first vertex_t::try_publish_edges onward the record is READ-ONLY. Nothing in the tree writes it after admission — index_link_vertex’s key choice, evict_link_edges’ link compare, evict_route_edges’ route compare, edge_view_of and the dispatch snapshot (vertex_t::copy_published, #1448) are all reads — which is what makes sharing it correct rather than merely cheap.

That immutability is the whole fix for #1442. Before it, a republish DEEP-COPIED this record into a fresh pub_remote_t per pre-existing entry, per admission — one nothrow operator new plus up to two std::string heap copies each — and scan_retired_edges freed them all again on the next pass. Measured at ~940 instructions per pre-existing edge against a ~158 inherent floor at 65 links (bench/README.md, Where the whole-subscribe growth goes), i.e. ~83 % of the constant spent reproducing bytes byte-identical to the ones being retired. A republish now copies a pointer and increments refs.

Public Members

std::string link

This node’s NAME for the link the subscribe arrived on.

FIRST, and the member ORDER below is the retired pub_remote_t’s, not this record’s historical one. That is deliberate and load-bearing: unifying the two halves means the DELIVERY path (graph_t::dispatch_edge_remote since #1448; vertex_t’s published-entry copy before it) reads this record instead of a published copy, and keeping the offsets it reads at exactly where they were is what kept that loop’s instruction stream identical across #1442. The slot-side readers (edge_view_of, evict_link_edges, evict_route_edges) move their displacements instead — control-plane paths, none of them pinned.

view_t return_route = {}

The consumer’s accumulated return route (a complete PATH TLV’s bytes — the FWD src the subscribe arrived with).

A write hands (link, this route, delivery_compact, value) to the graph’s injected remote-delivery sink, which emits the FWD{WRITE} (or auto-promoted COMPACT) back over the link (RFC-0004 §D/§E.1, ADR-0035 slice 4 / #136).

link is the discriminator, not this field: graph_t::dispatch_edge takes its remote leg on a non-empty link and reads this route without testing it. The two agree because the admitting door enforces it — graph_t::subscribe_wire refuses an empty route as INVALID_PATH (#1055), and the :subscribers[] field-write arm, which binds no route, deliberately leaves link empty (see the note at that door: assigning a link there would manufacture exactly the routeless delivery this invariant excludes). So on a published edge the two are populated together or not at all, and testing either one answers “is this subscriber remote?”. Held as a view over a REFCOUNTED segment (ADR-0041 §2): copied once at subscribe, then every delivery snapshot is a refcount clone — O(1) copies over the subscription’s life, and an in-flight delivery keeps the route alive across a concurrent unsubscribe. An opaque view, so L4 never depends on tr::net.

view_t reverse_route = {}

The COMPLETED reverse-direction bound route (RFC-0024 §7.1 amendment 1) — a PATH_REF TLV whose element 0 is THIS node’s own reference to the connection vertex the subscribe arrived on; empty ⇒ the subscription is canonical-only.

Stored at admission by graph_t::subscribe_wire when the mint-flagged subscribe carried a reverse list the responder could complete. On every delivery the producer consumes element 0 locally — validates it against its OWN vertex map (§6.2’s re-check) and egresses through the vertex it dereferences to — and puts elements 1.. on the wire as the delivery’s bound dst. A failed local validation (the link re-dialled; the generation moved) falls back to the canonical return_route, which is always stored alongside — the reverse binding is an optimisation plus a liveness check, never the only route. Same ownership shape as return_route — one refcounted copy at subscribe, refcount clones per delivery snapshot.

std::string caller

The caller context this edge was created under (#81, ADR-0026 fan-in gate).

The inbound link NAME for a remote subscribe, empty for a locally-wired edge. A fan-out re-dispatch into a LOCAL target vertex is gated by the TARGET’s :acl WRITE right under this context — the subscription’s creator is the “writer” the target authorizes. A REMOTE subscriber’s fan-in gate runs on the peer instead (its FWD{WRITE} terminus checks the same right).

bool delivery_compact = false

Route-handle opt-in (SUBSCRIBER.qos_settings.delivery_compact, RFC-0004 §E.1 / ADR-0035 slice 4).

When true the consumer requests label-compacted deliveries: the producer MAY advertise a per-link label aliasing this subscriber’s return route and thereafter stream lean COMPACT frames instead of full-route FWD{WRITE} deliveries. Default false ⇒ stateless full-route delivery, so a cold/one-shot flow allocates no label state.

view::detail::ref_count_t refs = {1}

Intrusive refcount (#1442): how many holders name this record — the slot, plus one per PUBLISHED edge array whose entry points at it.

Rides the record’s existing TAIL PADDING and therefore costs zero bytes. delivery_compact ends at offset 113 and the record is 8-aligned, so a 4-byte counter lands at 116 and sizeof stays the pinned 120 B. That is why the shape is an intrusive count and not a std::shared_ptr: a 16-byte handle would have widened subscriber_t (pinned at 80 B) AND pub_edge_t, whose width was measured at +23 % on the fan-out-1024 publish the last time it grew — the fix would have been paid for out of the delivery path.

Not a synchronization primitive for the PAYLOAD. The payload is written once, before the record is ever named by a published array, and the seq_cst exchange that publishes that array is what makes those bytes visible to a pinned reader — exactly the ordering the deep copy relied on. The COUNT is atomic because it is genuinely contended: two mutators can be inside scan_retired_edges at the same time (each pops a disjoint retire list after releasing the stripe lock) and both may drop the last reference to the same record — and since #1448 the pinned reader touches it too: the dispatch snapshot (vertex_t::copy_published) CLONES the handle, a relaxed increment taken while the pin guarantees the entry’s own reference still holds the count above zero.

tr::view::detail::ref_count_t is the in-tree primitive tr::view::segment_ptr_t already uses, LIBTRACER_NO_ATOMIC fallback included.

struct pub_edge_t

One entry of a PUBLISHED edge array: the hot dispatch fields plus a liveness bit.

Written once, before the array is published, and never touched again — that is what lets a reader copy it out with no lock. The active bit is the ONE mutable word, and it is MONOTONE: it starts true and an unsubscribe (vertex_t::clear_edge, vertex_t::evict_link_edges, retirement) flips it to false under the stripe lock. A reader loads it and skips the entry.

That single mutable bit is not a hedge on immutability, it removes a failure mode. Without it every unsubscribe would have to BUILD a smaller array, and an unsubscribe that cannot allocate would be left publishing an edge the caller has already torn its callback_ctx down behind. With it, dropping an edge is allocation-free and therefore infallible; the compaction that actually reclaims the dropped entry’s refcount clones rides the next successful publish, where a failure costs nothing but a delayed release.

Public Members

subscriber_fn_t callback = nullptr

In-process sink fn (null ⇒ target-only).

void *callback_ctx = nullptr

The sink’s caller-owned context.

target_key_t target_key

Local re-dispatch target (refcount share).

target_binding_t binding = {}

The minted slot for that target (#830).

remote_ptr_t remote

The cold wire half (null for a local edge) — a refcount SHARE of the admitting slot’s subscriber_remote_t, never a copy of it (#1442).

The entry’s WIDTH is the fan-out copy loop’s bandwidth, which is why this half is out of line at all: inlining its members made an entry 136 B against subscriber_t’s 72 and cost +23 % on the fan-out-1024 publish — measured, not predicted. Sharing keeps that width exactly where it was (one pointer, as the std::unique_ptr here was) while making a republish’s per-entry cost a pointer copy and an increment instead of a heap allocation and two std::string copies.

std::atomic<bool> active = {true}

Monotone true -> false liveness bit.

using tr::graph::subscriber_fn_t = void (*)(void *ctx, const rope_t &value)

The in-process per-edge delivery sink: a plain {fn, ctx} pair (the ADR-0047 hot-path shape, same doctrine as tr::net::receiver_slot_t).

Snapshotting one under the fan-out lock is a trivial copy — no per-publish std::function copy (which heap-allocates once captures exceed the SBO). The value crosses as the rope it is (ADR-0053 §6); the sink may clone links (refcount bumps).

using tr::graph::subscriber_release_fn_t = void (*)(void *ctx)

The hook a caller hands graph_t::unsubscribe so the LIBRARY can signal it that the retired subscription’s context is dead.

The direction is the whole point of ADR-0080 §Decision 4: the embedder never polls in-flight state and never waits. It registers this, and libtracer calls it exactly once, on the caller’s own thread, outside every graph lock, at the policy’s grace point. A contract of the form “the callback may still be invoked until you call X” is what that decision rejects.

Param ctx:

The callback_ctx the subscription was admitted with, handed straight back.

struct remote_delivery_t

What the producer fan-out hands a remote subscriber’s delivery sink (#136).

A pure description of one remote subscription edge: the consumer’s accumulated return route and this node’s NAME for the link it arrived on, both opaque to L4, plus the vertex_t::subscriber_t delivery_compact opt-in. The injected sink (a tr::net concern — graph_t::configure_remote_delivery_sink) interprets these: it maps link to a transport child and emits a full-route FWD{WRITE} or, when delivery_compact, an auto-promoted label COMPACT (RFC-0004 §D/§E.1). link is borrowed for the sink call only; return_route is a refcount clone of the stored route segment (ADR-0041 §2) — the sink may rope it into an egress frame, and it stays alive across a concurrent unsubscribe.

Public Members

std::string_view link

This node’s NAME for the consumer link.

view_t return_route

Consumer return route (PATH TLV view, refcount clone).

view_t reverse_route

Completed reverse bound route (PATH_REF view, refcount clone; empty ⇒ canonical-only). Element 0 is this node’s own reference, consumed locally by the sink per delivery — RFC-0024 §7.1 amendment 1.

std::string_view caller

The edge’s stored ACL fan-in context (#81) — the subject the sink’s local element-0 consumption re-checks §6.2 under.

bool delivery_compact = false

Opt-in to label-compacted delivery.

struct sub_event_t

One EXTERNAL mutation of a producer’s :subscribers[] — what graph_t::configure_subscription_observer reports.

“External” is exactly the ADR-0018 caller context being NON-EMPTY: the op arrived through op_resolver_t carrying an inbound link NAME. It is the same discriminator the SUBSCRIBE gate already runs under, so an observer sees precisely the set of edges a remote peer caused and never the ones the owner’s own wiring code made. The local doors — both subscribe() sugars, unsubscribe(), and a :subscribers[] field-write under the empty context — are deliberately silent: the host that called them already knows.

Both path fields are CANONICAL KEYS (concatenated NAME records — the PATH payload, docs/reference/03), never a slash-spelled string: that is the form the graph addresses by, and rendering one is the consumer’s choice, not a cost the event imposes. Both are BORROWED for the duration of the callback only — copy what outlives it.

Public Types

enum class kind_t : std::uint8_t

Which way the slot moved.

Values:

enumerator ADDED

A slot was appended, or an empty slot filled by a [N] replace.

enumerator REMOVED

An active slot was cleared, or displaced by a [N] replace.

Public Members

kind_t kind = kind_t::ADDED

Whether a slot gained a subscriber or lost one.

wire::key_view_t producer

Canonical key of the PRODUCER — the vertex whose :subscribers[] changed.

wire::key_view_t target

Canonical key decoded from the SUBSCRIBER’s PATH child — WHAT the record says, verbatim.

EMPTY when the record carries no well-formed PATH at all (a bare remote subscriber, whose consumer is named only by its return route over link).

Warning

Read it as the SPELLING the record carried, not as a local vertex. On a wire subscribe it is one of two things and the event cannot tell them apart: a path through one of THIS node’s mounts, which subscribe_wire resolves and binds the edge to (RFC-0021 §4.B.1), or the consumer’s address at ITS OWN root, which resolves to nothing here and is dropped as a re-dispatch target — delivery then rides the return route (RFC-0004 §D). On a local-target append it IS a key in this graph. The three are not distinguishable from the event alone; link tells the observer which transport the op came from, and the app’s own wiring says the rest.

std::string_view link

This node’s NAME for the transport link the op arrived on. Never empty.

std::size_t slot = 0

The :subscribers[] slot index the event concerns (RFC-0009 §D.2 stable).

using tr::graph::sub_observer_fn_t = void (*)(void *ctx, const sub_event_t &event)

The app-installable external-subscription observer.

Note

The ADR-0047 {fn, ctx} shape, NOT a std::function (#1049) — see subject_resolver_fn_t for why. ctx is caller-owned and must outlive every subscription mutation the graph can still report.

Warning

Runs SYNCHRONOUSLY on the resolver’s thread, inside the operation it reports, and the reply is not assembled until it returns — so it must be cheap and non-blocking, and it MUST NOT re-enter graph_t. It is called outside every graph lock (the admission door has already released the vertex stripe lock and the map lock), so a re-entrant call does not self-deadlock; it is refused on the simpler ground that an observer which mutates the graph while a :subscribers[] write is mid-flight makes the event stream depend on its own side effects. Deferral — queueing the event and acting on it from the app’s own task — is the APP’s job, exactly as it is for graph_t::configure_remote_delivery_sink.

Handlers and delivery policy

struct write_ctx_t

The per-call context a write carries into a HANDLER’s on_write (#375).

A HANDLER is the one seam where application code REACTS to a write, so it is the one seam that needs to know WHO wrote. The graph already resolved that identity one stack frame earlier — the ACL gate (graph_t::acl_allows) runs immediately before the handler, on the same value — so this type hands the handler the datum the gate just used rather than making it re-derive one. It is the ACL subject-table integration point (ADR-0018 — authorization over a pluggable subject token; ADR-0082 for why the subject is a claim of its own and not a spelling of peer_named): a handler that keys its own policy off subject keys it off exactly what the vertex’s :acl was evaluated against.

Note

This sentence used to cite RFC-0010, which is the wrong document — RFC-0010 is owner-writable application property fields (the field descriptor table, the reserved settings.app namespace and owner-defined :schema) and says nothing about subjects or access control. The subject-token model is ADR-0018’s; the decoupling of that token from peer addressing is ADR-0082’s, which itself points back at this comment as the integration point it feeds. Corrected with #375 Part 2.

Warning

LIFETIME — subject is BORROWED for the duration of the call, the SAME contract the rope_t& alongside it carries: COPY IF RETAINED. It views bytes owned by the router’s inbound frame or by the caller’s own storage, and both are gone the moment on_write returns. Stashing the string_view in a member, a map key, or a queued work item is a DANGLING read, not merely a stale one. Take a std::string (or the token’s bytes) if the identity must outlive the call.

Public Functions

inline constexpr bool is_local_owner() const noexcept

True iff this write came from the LOCAL HOST (the owner’s own API call) — i.e. subject is the empty owner token.

Public Members

std::string_view subject

The resolved SUBJECT token of the writer — the ACL model’s subject rights principal (CONTEXT.md §Access control, ADR-0018).

EMPTY means the LOCAL HOST: the owner’s own in-process write through the graph API. That is not a magic string but the very discriminator the ACL gate runs on — the empty caller context is the trusted-by-convention channel graph_t::acl_allows short-circuits BEFORE any resolver runs (#905), and a remote writer, which always carries a non-empty context, cannot spell it. Prefer is_local_owner to comparing against "".

Note

There is no OWNER@ sentinel and there must not be one: ADR-0020’s erratum (#1033) withdrew that name because no evaluator ever special-cased it, so an OWNER@ ACE matched nobody and LOCKED the vertex it was written to delegate. The owner sentinel here is the EMPTY token, which no ACE can spell.

Note

Non-empty, this is the operation’s caller context exactly as the gate saw it. The token is PLUGGABLE (ADR-0018, ADR-0045 raw-key ed25519 TOFU) — a stronger credential slots in without changing this seam or the ACL model.

struct handlers_t

User behavior for a Handler-role vertex.

on_children additionally applies to ANY role: when set, a read of the vertex’s :children[] field serves this synthesized member listing (a complete POINT TLV view) INSTEAD of enumerating registered child vertices — the ADR-0044 seam by which a transport/connection vertex lists its live bus peers without ever creating a vertex for them. The value seam is rope-typed (ADR-0053 §6): on_read supplies the vertex value as the rope it is (a contiguous scalar is the single-link case), on_write receives the written value without a flatten copy.

Public Members

std::function<result_t<rope_t>()> on_read

Supplies the vertex value on read.

std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write

Receives the written value and the writer’s write_ctx_t (#375). Both arguments are borrowed for the call only — copy if retained.

std::function<result_t<view_t>()> on_children

Synthesized :children[] listing.

std::function<void(std::string_view name, const view_t &value)> on_app_field_write

The owner apply seam (RFC-0010 §A.3): fires after a declared :settings.app.<name> field write stored its bytes, with the field’s key (below settings.app.) and the written TLV — OUTSIDE the vertex lock, so it may re-enter the graph (apply the config, restructure children, then ANNOUNCE the change with an ordinary data write per §C — the field write itself never wakes await and never propagates). Unset ⇒ the bytes just store (a passive metadata field).

struct value_handlers_t

The internal, lazily-allocated STORAGE of a vertex’s VALUE seam (ADR-0058 Step 2) — the three seams handlers_t carries minus on_app_field_write.

Split off from the public handlers_t input so a vertex that installs none of the three never allocates these ~96 B of std::function: the block lives behind a lazily published pointer in the extension block, null unless at least one of on_read, on_write, on_children was given. Allocation is keyed on that PRESENCE, not on role_tadopt_identity never consults the role — so a STORED_VALUE vertex given an on_children (the /net/<module>/<name> identity vertex of a bus link) does carry one, and a HANDLER vertex registered with an empty handlers_t does not. Which of the three is ever CONSULTED is a separate, per-seam question: on_read / on_write run only on a HANDLER-role target, while on_children serves the synthesized listing whatever the role. on_app_field_write co-occurs with app fields, not the value seam, so it moved to app_field_group_t. Set once at registration (vertex_t::adopt_identity), read lock-free thereafter.

Public Members

std::function<result_t<rope_t>()> on_read

Supplies the vertex value on read.

std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write

Receives the written value and the writer’s write_ctx_t (#375). Both arguments are borrowed for the call only — copy if retained.

std::function<result_t<view_t>()> on_children

Synthesized :children[] listing.

struct delivery_policy_t

One subscription’s DELIVERY policy — a packed 16-bit field carried in the SUBSCRIBER TLV’s SETTINGS child (RFC-0022 §3.A).

Delivery policy describes one producer→subscriber relationship, not the producer: a vertex that fans out to a CAN peer and a WebSocket peer at once has no single reliability or priority to hold, which is why these lived on the vertex for a year without anything ever consuming them. DDS puts the same three on the reader/writer pair for the same reason.

bits

field

values

0–1

reliability

0 = best-effort, 1 = reliable; 2–3 reserved

2–4

priority

0–7, 0 = default

5

durability_request

1 = deliver the latched last value on join

6–7

delivery_class

0 = conflate (default), 1 = immediate, 2 = batch, 3 = stream

8–15

reserved

MUST be written 0, MUST be ignored on read

Absent from the wire ⇒ all-zero ⇒ today’s default behaviour, byte-identically — and the class field costs no wire byte for the same reason: 0 is conflate, which is what every pre-RFC-0025 subscriber wrote into those bits when they were reserved. Old subscribers are conflate-class BY CONSTRUCTION.

Only durability_request is consumed today (the transient-local latch at graph_t::admit_subscriber); reliability, priority and delivery_class are stored and read back, awaiting the fan-out-edge and receiver-ring work that honours them — the honest shape RFC-0022 §3.E chose over moving dead per-vertex fields.

Flags only, never a magnitude. A deadline or a queue bound added later is a magnitude and belongs in the subscription’s cold half as a full-width field, never in these bits.

Public Functions

inline constexpr std::uint8_t reliability() const noexcept

0 = best-effort, 1 = reliable (2–3 reserved; stored, never interpreted).

inline constexpr std::uint8_t priority() const noexcept

0–7, 0 = default.

inline constexpr bool durability_request() const noexcept

True iff THIS subscriber asked for the latched last value on join.

inline constexpr delivery_class_t delivery_class() const noexcept

Bits 6–7 — how the fan-out edge treats this subscriber’s deliveries (RFC-0025 §4.1).

Every two-bit pattern is an assigned class, so this accessor is total: there is no “unknown class” to reject, and a word from a future sender still decodes to one of the four. Reading the field is not honouring it — the classes beyond CONFLATE land with the fan-out-edge mechanics and the receiving vertex’s ring.

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

Memberwise equality on the raw bits (reserved bits included — they are carried verbatim, so two policies differing only there are not the same bytes).

Public Members

std::uint16_t bits = 0

The packed field, as it arrived off the wire.

Public Static Attributes

static constexpr std::uint16_t kReliabilityMask = 0x0003

Bits 0–1.

static constexpr std::uint16_t kPriorityMask = 0x001C

Bits 2–4.

static constexpr int kPriorityShift = 2

Bits 2–4 offset.

static constexpr std::uint16_t kDurabilityRequest = 0x0020

Bit 5.

static constexpr std::uint16_t kDeliveryClassMask = 0x00C0

Bits 6–7.

static constexpr int kDeliveryClassShift = 6

Bits 6–7 offset.

enum class tr::graph::delivery_mode_t : std::uint8_t

Per-VERTEX propagation policy (value-agnostic; RFC-0008 §C).

Governs whether an ANCESTOR’s propagate sweep includes this vertex — NOT a per-subscriber value filter (there is no byte comparison; ADR-0053 §1, a vertex never parses its bytes). assign and a DIRECT propagate on the vertex itself are never gated by it. Held as vertex state (default IF_NEWER); wire config via the vertex :settings is deferred. Numeric filtering (deadband) remains an application filter vertex (ADR-0021 sibling), never a field here.

Values:

enumerator IF_NEWER

Default: an ancestor sweep includes this vertex only if it was assigned since the last covering sweep — the structural coalescing flush (RFC-0008 §B).

enumerator UNCONDITIONAL

An ancestor sweep ALWAYS includes this vertex’s current value (a sweep-driven keepalive; the producer’s timer sets the rate).

enumerator EXPLICIT

An ancestor sweep NEVER includes it; deliverable only by a direct propagate on the vertex itself.

class value_ref_t

An owning reference to a vertex’s PUBLISHED value — what graph_t::read and graph_t::await hand back.

The value a vertex publishes is already refcounted: the LKV slot holds it as a std::shared_ptr<const rope_t>, and the policy contract in lkv_slot.hpp fixes that shape because load() must return an OWNING handle. A read therefore has a choice — hand the caller that reference, or copy the rope out of it. Copying is not free: a rope copy clones one segment_ptr_t per link, and each clone is a contended refcount RMW on a line every reader of that vertex shares, so it costs more as links grow AND as readers grow.

Measured on the real path, both arms alternating inside ONE binary (24-thread host, 102 paired samples): median 1.37x aggregate, 89/102 samples favouring the reference, and p50 improving most where it hurts most — 2,104 ns to 1,193 ns at sixteen readers on one shared vertex. The composed BRANCH read, which must build a value rather than share one, measured 1.00x (15/30): the shape that cannot benefit does not pay either.

The rule this draws: a read of a PUBLISHED value returns a reference to it; a read that COMPOSES a new value returns the value. That is why graph_t::read_children_folded and its siblings still return a rope_t — there is no published object for them to reference.

Holding one keeps that value alive, exactly as the reader’s own reference did before. Under an injected std::pmr::memory_resource that is a real obligation: the value was allocated from the graph’s resource, so an outstanding reference pins it (ADR-0069, deferred reclamation).

Public Functions

inline explicit value_ref_t(std::shared_ptr<const rope_t> p) noexcept

Wrap a published value’s handle.

inline const rope_t &operator*() const noexcept

The referenced value. Undefined if this reference is empty.

inline const rope_t *operator->() const noexcept

Member access on the referenced value.

inline const rope_t *get() const noexcept

The referenced value, or null.

inline explicit operator bool() const noexcept

Whether this reference names a value.

Public Static Functions

static inline value_ref_t composed(rope_t &&r)

Take ownership of a freshly COMPOSED value, giving it a published value’s shape.

The composed branch read builds a rope no vertex published; this is what lets it answer the same signature. It allocates a control block, which the published path does not — measured neutral (1.00x over 30 paired samples), because a subtree walk dominates it.

Edges

struct edge_view_t

The edge record’s WIDTH, pinned (#1266).

Both halves are per-edge storage on a node whose subscriber arena is measured in kilobytes, and the hot half is what the fan-out loop streams — #380 §3 split the cold members out for exactly that reason, and the split is only worth anything while the hot record stays narrow. These numbers moved silently more than once (a member added to the wrong half costs nothing a test can see until the RAM census runs), so they are stated where a change to either struct has to walk past them.

64-bit hosts only: the widths are pointer-sized-member sums, so an MCU build legitimately differs and a sizeof pin there would be a false alarm rather than a guard.

#1442 moved the cold half from owned-per-holder to refcount-shared and both numbers are unchanged: the handle is one pointer, as the std::unique_ptr was, and the intrusive count fits the cold record’s pre-existing tail padding. A future member that pushes subscriber_remote_t past 120 B evicts the counter into a word of its own and costs 8, not 4 — that is the growth this pin is here to price.

The dispatch-relevant snapshot of one ACTIVE subscription edge — four words and two refcounts, no byte copy of anything (#1448).

What vertex_t::snapshot_edges copies out under an edge pin so the graph can dispatch with the pin released (callbacks / re-dispatch re-enter the graph): the {fn, ctx} callback pair, the minted binding, and refcount SHARES of the two owned records — the target key and the whole cold half.

Why the cold half is shared here and not copied (#1448). #1442 made subscriber_remote_t refcount-shared and immutable after admission, and #1447 spent that on the SUBSCRIBE path (vertex_t::try_publish_edges). This snapshot is the same copy on the DELIVERY path, where it is paid once per edge per write rather than once per admission: it used to own std::string copies of the link and the caller plus refcount clones of the two routes, i.e. two probe-guarded assignments and two atomics for every remote edge of every fan-out. It is now ONE relaxed increment, and the record it names is exactly the bytes the copy used to reproduce. The lifetime guarantee the copies bought — the slot may be cleared while dispatch runs outside the pin — is bought instead by the share itself: this handle is a holder, so the record outlives the unsubscribe that drops the slot’s.

That also makes the whole snapshot INFALLIBLE. Nothing in it can allocate (a shared_ptr clone and an intrusive increment do not), so vertex_t::copy_published no longer has a per-edge OOM leg at all and vertex_t::snapshot_drops_t no longer carries an out_of_memory count — the shed it used to describe cannot happen.

ADR-0041 §2 is satisfied more strongly than before, not stretched: its remote-subscriber row asks for one copy at subscribe into a refcounted segment with every later delivery roping the stored route rather than copying it. The routes already complied as view_t clones; now the delivery does not even clone them, and nothing here is a borrowed span — the handle owns.

The width matters on its own: 160 B → 48 B. edge_snapshot_t is kCapacity of these on the publishing thread’s stack, and a wide fan-out streams F of them through the overflow vector, which is the F * sizeof(edge_view_t) term bench/bench_common.hpp names as the reason the mid fan-out ladder exists.

Public Functions

inline std::string_view link() const noexcept

This edge’s remote-delivery link NAME; empty ⇒ no remote leg.

Borrowed from the record this view holds, so it is valid for as long as the view is — which is exactly as long as the std::string member it replaces was. Empty for an in-process edge AND for the :subscribers[] field-write arm, which binds a caller context but deliberately no link (there is no return route to deliver over).

inline std::string_view caller() const noexcept

The edge’s stored ACL fan-in context (#81), borrowed from the held record; empty for a locally-wired edge.

inline bool has_remote_leg() const noexcept

Does this edge have a REMOTE-delivery leg — i.e. a non-empty link?

The gate graph_t::dispatch_edge takes per edge, kept as one named test because it is on the always-inlined per-edge body of the wide fan-out loop. The null check short-circuits, so the in-process edge — the bulk of any fan-out — pays one load and one branch, exactly what link.empty() cost when the string was inline.

Public Members

subscriber_fn_t callback = nullptr

The in-process sink fn (null ⇒ none).

void *callback_ctx = nullptr

The sink’s caller-owned context.

target_key_t target_key

Local re-dispatch target (refcount share, not a copy).

target_binding_t binding = {}

The minted slot for that target, or unbound (#830).

remote_ptr_t remote

The shared, immutable cold half (#1442) — null for the plain in-process edge. A HOLDER, not a borrow: it keeps the record alive for the whole dispatch, which is what the owning string copies used to do.

class edge_snapshot_t

The fixed-capacity stack buffer of edge_view_t dispatch views — the no-heap small-fan-out half of vertex_t::snapshot_edges.

The element storage is RAW (uninitialized) bytes: declaring one on the publish hot path costs nothing, and only the views actually snapshotted are placement-constructed (and destroyed). A default-constructed std::array<edge_view_t, 8> here instead zeroed ~900 bytes of stack per publish — GCC lowers that to eight rep stos blocks whose microcode startup latency dominated single-subscriber fan-out (the post-v0.3.0 fan1 delivery regression). Non-copyable; reused via clear.

Public Functions

edge_snapshot_t() noexcept = default

An empty snapshot; the element storage stays uninitialized (the point).

edge_snapshot_t(const edge_snapshot_t&) = delete

Non-copyable — a transient dispatch buffer, never a value.

edge_snapshot_t &operator=(const edge_snapshot_t&) = delete

Non-assignable — a transient dispatch buffer, never a value.

inline ~edge_snapshot_t()

Destroy the constructed views (only those actually snapshotted).

inline void push_back(edge_view_t v)

Placement-construct v as the next view; the caller (the snapshot loop) keeps the count ≤ kCapacity.

inline void clear() noexcept

Destroy every constructed view; the buffer is reusable afterwards.

inline std::size_t size() const noexcept

The number of views constructed.

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

The i-th snapshotted view (i < size).

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

The i-th snapshotted view (i < size), const.

Public Static Attributes

static constexpr std::size_t kCapacity = 8

The snapshot width (mirrored as vertex_t::kInlineFanout).

struct edge_latch_t

The dispatch snapshot’s WIDTH, pinned — the delivery path’s bandwidth (#1448).

snapshot_edges writes one of these per active edge on every fan-out, kInlineFanout of them live on the publishing thread’s stack, and a wide fan-out streams F through the overflow vector. #844’s mid ladder exists because that array outgrows L1 somewhere in the 128→1024 gap, so the width is a measured hot-path quantity and not a housekeeping detail. 160 B before #1448 (two std::strings and two view_ts inline), 48 B after.

A transient-local durability latch (RFC-0004 §D / Q4): the LKV plus the freshly admitted edge’s dispatch view, both snapshotted atomically with the append.

value stays null when no latch fired (the subscriber requested no durability — RFC-0022 §3.A — or the producer holds no LKV yet).

Public Members

std::shared_ptr<const rope_t> value

The latched LKV; null ⇒ no latch.

edge_view_t edge

The admitted edge’s dispatch view.

Owner app fields

enum class tr::graph::app_access_t : std::uint8_t

Owner-declared REMOTE writability of one application property field (RFC-0010 §A.2): what a caller-attributed field write/read may do. The OWNER — a local, caller-less host API call — always reads and writes its own declared fields; ro/wo constrain remote callers only.

Values:

enumerator RO

Remote read only — a remote write has no surface (SCHEMA_NOT_FOUND).

enumerator RW

Remote read + write (a write still passes the vertex WRITE gate).

enumerator WO

Remote write only — no read surface: a secret never mirrors back.

struct app_field_t

One entry of a vertex’s field descriptor table (RFC-0010 §A.2/§B): declaration, remote-writability, self-description, and current value of ONE application field under :settings.app. — one record, so the schema can never drift from the gate.

The value and descriptor bytes are OPAQUE to the runtime (stored and served verbatim — the last store-verbatim control surface, now that :acl re-encodes from its parsed projection, #907): dtype/range validation is the owner’s, in its apply seam (handlers_t::on_app_field_write) — the runtime validates only addressing (declared / undeclared, writability): one table lookup.

Public Members

std::string name = {}

The field’s key below settings.app. — a .-joined spelling of the field steps ("kp", "wifi.ssid"); the runtime keys the joined string flat.

app_access_t access = app_access_t::RO

Owner-declared remote writability.

std::vector<std::byte> descriptor = {}

The §B.1 descriptor record members (dtype/unit/min/max/label…, concatenated child TLVs) served inside this field’s :schema entry VERBATIM, after the runtime-projected access member. Never parsed by the runtime.

std::vector<std::byte> value = {}

The field’s current TLV bytes, stored and served verbatim (§D). Empty ⇒ never written (reads NOT_FOUND; omitted from container reads). An install MAY carry an initial value here.

using tr::graph::app_field_static_t = app_field_slot_t

The install-time spelling of app_field_slot_t — the same type. Kept as a name because it reads better at an owner’s set_app_fields_static call site, and because it is the spelling already in the wild (docs, integrations, firmware tables).

struct app_field_slot_t

One app-field DECLARATION (ADR-0058, class ②): view-shaped, owning nothing.

Unlike app_field_t this owns NOTHING: name and descriptor are VIEWS. For an OWNING install they point into app_field_table_t::backing; for a BORROWED install (graph_t::set_app_fields_static) they point at the caller’s own storage, and the caller guarantees the pointed-to bytes — and the array holding these entries — outlive the vertex. Pass static storage (flash / .rodata), never a stack array or a soon-freed heap block. Either way the storage is immutable for the table’s lifetime, so the views stay valid. Declaration only: no initial value (write values after install via the field-write surface).

This is ONE type serving both roles. It used to be two — app_field_static_t for the install-time shape and app_field_slot_t for the runtime’s copy of it — which were field-for-field identical, so a borrowed install spent an allocation and a copy converting between them. Unifying them lets a borrowed table be viewed in place (ADR-0058 erratum 1).

Public Members

std::string_view name

Field key below settings.app. (§A.1).

app_access_t access = app_access_t::RO

Owner-declared remote writability.

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

§B.1 descriptor bytes, served verbatim.

struct app_field_group_t

The lazily-allocated APP-FIELD group of the extension block (ADR-0058 Step 2): the RFC-0010 descriptor table plus its owner apply seam, together.

on_app_field_write co-occurs with the field table (it is the table’s apply seam), NOT with the vertex’s value seam — so it lives here, not in value_handlers_t. A vertex with no app fields and no apply seam keeps this group null and pays neither the table nor the ~32 B std::function. Allocated on the first of either set_app_fields* (the table) or an on_app_field_write at registration; guarded by the vertex mutex, insert-only (never freed before the vertex).

Public Members

app_field_table_t table

The view-slot descriptor table + lazy value store.

std::function<void(std::string_view name, const view_t &value)> on_app_field_write

The owner apply seam (RFC-0010 §A.3): fires after a declared field write stored its bytes, OUTSIDE the vertex lock. Unset ⇒ bytes just store.

struct app_field_table_t

A vertex’s RFC-0010 field descriptor table (ADR-0058): the immutable declaration (class ②) split from the per-vertex mutable values (class ③).

Both install overloads converge here. set_app_fields_static leaves backing empty and points slots straight at the caller’s array — the declaration costs zero RAM, neither bytes nor slots (measured host-side: 392 B / 10 allocs per leaf versus 695 B / 17 for the owning install, against a 136 B bare leaf — the vertex_app5_static and vertex_app5 gate rows). Erratum 1 is what removed the slot copy; an earlier revision of this comment still described it (592 B / 11) after the code had stopped doing it. The owning set_app_fields packs the runtime table’s name+descriptor bytes into backing — ONE allocation for the whole table — and points the slots into it. backing is never mutated or reallocated while slots reference it (a re-install replaces the whole table under the vertex mutex).

Public Members

std::span<const app_field_slot_t> slots = {}

Per-field declaration views, in owner install order. Empty ⇒ no table installed (the closed ENOTTY default). Guarded by the vertex mutex.

A SPAN, not a container: a borrowed install points it straight at the caller’s array and allocates nothing for the declaration, which is what ADR-0058 §Step 1.2 promised and did not deliver (it copied into a std::vector — see erratum 1). An owning install points it at owned_slots. Stable across the table’s moves for the same reason backing is: a moved unique_ptr keeps its heap address, and a borrowed span points outside the table entirely.

std::unique_ptr<app_field_slot_t[]> owned_slots = {}

The owning install’s slot array; null for a borrowed install. A unique_ptr<T[]> rather than a vector so the table stays the same size as when slots was the vector (pointer + span == vector on both host and rv32) and drops the vector’s capacity word. Never resized: a re-install builds a whole new table and move-assigns it under the stripe lock.

std::vector<std::byte> backing = {}

Owned copy of the declaration bytes for the owning install (name then descriptor, concatenated per field); empty for a borrowed install whose slots view caller storage.

std::unique_ptr<std::vector<std::vector<std::byte>>> values = {}

Class-③ per-field values, index-aligned with slots — LAZILY allocated, null until the first field write on this vertex (#389 pattern). A declared-but-never-written table costs zero value RAM. (*values)[i] empty ⇒ field i unset.

class borrowed_fields_t

The argument type of a BORROWED app-field install — a table the caller promises outlives the vertex, constrained at compile time to storage shaped like it does (ADR-0058 erratum 2).

Erratum 1 tightened the borrowed install’s contract from “the `name`/`descriptor` bytes

must outlive the vertex” to “**the array too**”. Because the parameter was a

std::span, which binds implicitly to any contiguous range, that tightening reached callers as a SILENT change: the same call kept compiling and started dangling. This type closes the common case of that trap. It converts implicitly from a T[N] or a std::array — the two spellings a constexpr/static table takes — and NOT from a std::vector, so the natural way to build a table dynamically (fill a vector, install it, return) is now a compile error at the call site rather than a use-after-free found later by a downstream test suite. Default-constructed ({}) is the empty table, which uninstalls.

What it does NOT prove: that the storage is static. A block-scope T[N] binds exactly like a namespace-scope one — C++ cannot express “static storage duration” as a constraint on a parameter. It rejects the container/temporary class of mistake, not every lifetime mistake. A caller whose table really is runtime-sized (a binding mapping a foreign POD array into slots, e.g.) opts out through unchecked, whose name is the point: the lifetime promise moves to the caller, in writing, at the call site.

Public Functions

constexpr borrowed_fields_t() noexcept = default

The empty table — installs nothing, uninstalls an existing one.

template<std::size_t N>
inline constexpr borrowed_fields_t(const app_field_static_t (&table)[N]) noexcept

Borrow a C array of declarations — the static constexpr kFields[] spelling.

Parameters:

table – The caller’s array; it and the bytes it points at MUST outlive the vertex.

template<std::size_t N>
inline constexpr borrowed_fields_t(const std::array<app_field_static_t, N> &table) noexcept

Borrow a std::array of declarations — same contract as the C-array form.

Parameters:

table – The caller’s array; it and the bytes it points at MUST outlive the vertex.

inline constexpr std::span<const app_field_static_t> slots() const noexcept

The borrowed slots, in owner install order.

inline constexpr bool empty() const noexcept

True when the table declares no fields — the uninstall case.

Public Static Functions

static inline constexpr borrowed_fields_t unchecked(std::span<const app_field_static_t> table) noexcept

Borrow an arbitrary span, asserting the lifetime by hand — the escape hatch for a table whose extent is only known at run time.

Use when the storage is genuinely long-lived but not array-shaped at the call site: a language binding filling a .bss slot array from a foreign POD table, say. The spelling is deliberately unpleasant — it is the caller taking the promise the implicit constructors would otherwise have checked the shape of.

Parameters:

table – Slots that MUST outlive the vertex, along with the bytes they point at.

Lock striping

struct vertex_stripe_t

One shared lock stripe: the mutex + condvar a SET of vertices ride (#361 §2), replacing a per-vertex std::mutex + std::condition_variable.

Why: the blocking primitives were the single largest per-vertex RAM cost on the MCU target — ESP-IDF pthreads lazily allocate a FreeRTOS mutex (~90 B) plus condvar state PER VERTEX on first touch, and the host paid 88 B of struct. The LKV read/write hot path takes no VERTEX lock (the atomic shared_ptr swap), so a stripe serializes only control-plane verbs (ring trim, edge mutation, ACL state, seq/notify) — cross-vertex contention is wiring-frequency, not per-publish. await waits on the stripe’s condvar with a PER-VERTEX predicate (write_seq_), so a collision costs a spurious wake + re-check, never a correctness change.

Public Members

std::mutex m

Serializes the stripe’s vertices’ verbs.

std::atomic<int> waiters = {0}

Live await waiters on this stripe. Mutated only under m, but READ without it by a publish that never takes the lock at all (#555), so it is atomic: the waiterless publish skips the mutex, not just the condvar call that #370 skipped. See vertex_t::store for the ordering argument that makes the lock-free read safe against a lost wakeup.

inline std::size_t tr::graph::vertex_stripe_index(const void *v) noexcept

The stripe slot of a pinned vertex address (ADR-0056/0057 — the address is a stable identity). Same vertex ⇒ same slot, always.

inline vertex_stripe_t &tr::graph::vertex_stripe_at(std::size_t idx) noexcept

The stripe table: constinit where the platform’s std::mutex is constexpr-constructible, so the per-verb lookup is a plain indexed load with NO function-local-static init-guard check on the hot path (#370). libstdc++ makes the ctor constexpr only when its gthreads port supports static mutex init (__GTHREAD_MUTEX_INIT) — ESP-IDF’s does NOT — and libc++’s always is; the fallback is a guarded function-local static (one predicted branch per verb — the MCU’s constraint is RAM, not that branch). The condvars live in a separate guarded table (vertex_stripe_cv) because std::condition_variable can never be constant-initialized — only the cold await/wake paths reach it.

The stripe at table slot idx (guarded-static fallback: this platform’s std::mutex has no constexpr ctor, so the table cannot be constinit).

See: path, views, status & errors, security & ACL, config, interface-map.