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 vertex_policy_t::ring_source (RFC-0025 §4.6.1) — whose depth the owner declares host-side with vertex_policy_t::retention, 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 a one-word value_t* swap under the slot policy (RFC-0028 slice 3), 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.

What a vertex keeps after a write is one policy, retention_t { NONE, LAST, N } (RFC-0028 §5.4), declared owner-side with vertex_policy_t::retention. Each role has a default — a handler keeps NONE, a stored value LAST, a stream N — and a stored value or a stream may be declared NONE: the pure relay, which delivers every write to its subscribers, keeps nothing, and answers read with NOT_FOUND. A relay whose subscribers are all callbacks delivers from the writer’s stack and draws no block at all. App fields carry the same enum: a wo field is NONE, so its write reaches on_app_field_write and is stored nowhere.

The slot is a build-time policy. By default it is single_writer_slot_t, whose one wait is a guard that never spins (#1618): a striped mutex on a host, an interrupt-masked section on a chip. A host can opt into hazard_slot_t, which is lock-free but still pays one contended increment to promote a read into an owning handle. The claim the code supports is the mutex one, not an absence of contention; the costs 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:There is no in-process; 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.

Retention is vertex state. A subscription’s latch (durability_request) and its enabled bit are edge state, separate from retention.

Interface

enum class role_t { STORED_VALUE, STREAM, HANDLER };
enum class retention_t { NONE, LAST, N };  // RFC-0028 §5.4 — per vertex AND per app field
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 vertex_policy_t below
// (RFC-0028 §4.12, D12). 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()
    const net::link_kind_t* link;  // the arrival link's catalog (kind, role); null = none (#1650)
};                           // BORROWED for the call, like the value beside it — copy if retained

template <class R, class... A>
struct hook_t<R(A...)> {     // THE callback idiom (RFC-0028 D10): {fn, ctx}, 16 B, owns nothing
    R (*fn)(void* ctx, A...);
    void* ctx;               // caller-owned; must outlive the vertex
};
thunk(f);                    // hook over a callable you keep alive (a stateless one: any lifetime)

struct handlers_t {                                       // six hooks, 96 B on the host
    hook_t<result_t<value_ref_t>()>                               on_read;    // one read type
    hook_t<result_t<void>(const value_t&, const write_ctx_t&)>    on_write;   // BY REFERENCE
    hook_t<result_t<view_t>()>                                    on_children;
    hook_t<admission_t(const value_t&, const write_ctx_t&)>       on_admit;
    hook_t<result_t<view_t>(std::string_view, const view_t&, const write_ctx_t&)>
                                                                  on_app_field_admit;
    hook_t<void(std::string_view, const view_t&)>                 on_app_field_write;
};                            // keep a value past on_write/on_admit: value_ref_t::keep(value)

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

template <class Fn> struct graph_hook_t { Fn fn; void* ctx; };  // one graph-wide seam
struct graph_hooks_t {        // the five graph-wide seams as ONE aggregate (RFC-0028 D9)
    graph_hook_t<subject_resolver_fn_t> subject_resolver;       // ACL enforcement switch
    graph_hook_t<sub_observer_fn_t>     subscription_observer;
    graph_hook_t<remote_delivery_fn_t>  remote_delivery;        // the router installs it
    graph_hook_t<wire_target_fn_t>      wire_target;            // the router installs it
    graph_hook_t<stats_sampler_fn_t>    stats_sampler;          // the router installs it
};

struct vertex_policy_t {      // everything the OWNER declares about one vertex (RFC-0028 D12)
    std::optional<retention_t> retention;          // unset ⇒ the role's default (§5.4)
    std::uint32_t              depth = 1;          // ring depth under retention_t::N
    std::size_t                share_threshold_bytes = kShareThresholdBytes;  // §5.3
    mem::block_source_t*       ring_source = nullptr;   // RFC-0025 §4.6.1; null ⇒ graph default
    bool                       ring_reliable = false;   // §4.4 pressure arm
    delivery_mode_t            delivery_mode = delivery_mode_t::IF_NEWER;
    app_fields_decl_t          app_fields;         // owning OR borrowed (RFC-0010 §A)
};

class graph_t {
    explicit graph_t(mem::block_source_t& src = mem::heap_source(), graph_hooks_t hooks = {});

    // registration and removal — the policy is applied WHOLE, at registration
    vertex_handle_t register_vertex(const path_t&, role_t, handlers_t = {},
                                    vertex_policy_t policy = {},
                                    std::span<const payload_right_t> rights = {});
    result_t<vertex_handle_t> try_register_vertex(const path_t&, role_t, handlers_t = {},
                                                  vertex_policy_t policy = {},
                                                  std::span<const payload_right_t> rights = {});
    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 = {},
                                const net::link_kind_t* link = nullptr);  // #1650
    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);
    result_t<std::size_t> history(vertex_handle_t, std::span<value_ref_t> out) const;  // 0 allocs
    result_t<value_ref_t> read (vertex_handle_t, const field_path_t&,       // ONE read type
                                std::string_view caller = {}) const;        // (RFC-0028 D11)
    // 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<value_ref_t>& out,
                                          std::uint64_t* gap_before = nullptr);
    result_t<void>        mark_flushed(vertex_handle_t);

    // owner-side declarations (RFC-0022 §3.C) — host API only, NO wire surface. ONE verb:
    // the policy is stated whole, so a member left at its default resets that property.
    result_t<void> set_policy(vertex_handle_t, vertex_policy_t);  // SCHEMA_NOT_FOUND if illegal
    retention_t   retention             (vertex_handle_t) const noexcept;
    result_t<std::size_t>   ring_reserved_bytes(vertex_handle_t) const;
    result_t<std::uint64_t> stream_gaps        (vertex_handle_t) const;
    std::size_t   share_threshold_bytes    (vertex_handle_t) const noexcept;

    // graph-wide seams — read-modify-write, for a party wired after construction (the router)
    void          set_hooks(const graph_hooks_t&) noexcept;
    graph_hooks_t hooks() const noexcept;

    // composed reads — they build a value, so they return a reference to a fresh block
    result_t<value_ref_t> read_children_folded(vertex_handle_t) const;
    result_t<value_ref_t> read_children_materialized(vertex_handle_t) const;
    result_t<value_ref_t> read_subtree_folded(vertex_handle_t, ...) const;

    // field plane (`:`-addressed)
    result_t<value_ref_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 anywhere on the graph’s seams (RFC-0028 D10): every hook — the subscription edge, the six handlers_t seams, the child-type catalog — is a {fn, ctx} pair, and a caller keeps the ctx alive. A seam that is handed const value_t& (a callback edge, on_write, on_admit) borrows it for the call; to keep it, take value_ref_t::keep(value), which shares a published block and copies the links out of a relay’s stack storage. There is 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:graph_t::unsubscribe(const subscription_t& sub)); 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:@param ctx Caller-owned 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:graph_t::read(vertex_handle_t v, std::string_view, core/include/libtracer/graph.hpp:graph_t::await(vertex_handle_t v, by handle, core/include/libtracer/graph.hpp:graph_t::read(const path_t& path), core/include/libtracer/graph.hpp:graph_t::await(const path_t& path, by path; value_ref_t at core/include/libtracer/value.hpp:value_ref_t). A value_ref_t is an owning reference to the value the vertex published: the LKV slot holds one intrusive value_t* — a refcount, the link count and the link chain in a single block drawn from the vertex’s block_source_t — so handing that reference back costs one refcount increment 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 block_source_t that is a real obligation rather than a formality: the value’s block was drawn from the graph’s source, 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.

A published value_t is one block holding a refcount and the written rope’s links, not the bytes they point into: it is neither a rope_t nor a segment. The value is the storage form and a rope stays the write and egress form; a sink that needs a rope clones the links with value_t::rope.

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.

vertex_policy_t::delivery_mode sets that per-vertex policy, declared at register_vertex or through set_policy. It is a wiring-time host declaration, stated alongside retention, the share threshold, the ring source and the app field table — 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:status_t) 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:error_code — 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 callback seams are {fn, ctx} slots of one graph_hooks_t aggregate (RFC-0028 D9) — handed to the constructor, or installed later with set_hooks (a router wired after its graph reads hooks(), sets its three, and writes the whole back) — 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. Generic, and still live — but not the door to a connection any more: the net plane’s client / listener types were unregistered at RFC-0014 S7, which left /net/<module>/conn the only connection-creation surface

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

graph_hooks_t::remote_delivery

where the producer fan-out hands each remote subscriber’s delivery (the router installs it)

null — remote subscriber slots are stored but never deliver

graph_hooks_t::subject_resolver

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).

A subscriber’s const value_t& is valid for the call only, and it may be an on-stack value_storage_t that a branch write built over a slice it never stored.

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
};

// declared through vertex_policy_t::app_fields (an app_fields_decl_t), at registration or
// through set_policy — one member, two spellings:
g.set_policy(v, {.app_fields = std::vector<app_field_t>{...}});  // owning (moved in)
g.set_policy(v, {.app_fields = kFlashTable});                    // borrowed, zero-copy

owning (std::vector<app_field_t>)

borrowed (borrowed_fields_t)

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

:acl, :subscribers, :children and :schema are addressed whole

:<field>.<anything>, and :<field>[N] on a non-array field, name nothing and answer ERROR{tr::schema::not_found}. One shared field_selector / whole_field classification enforces it; without it a trailing step would fall through to the branch’s action (:children[].bogus would create a child) and report success.

collect() is not reclamation

it neither waits for nor detects readers: it frees parked seams at a moment the embedder names, on the caller’s thread, after releasing the map lock, so a seam destructor may re-enter the graph. Call it only where no lock-free reader holds a seam; it is not the pattern for the subscriber edge array a fan_out reader holds across dispatch.

Teardown is only a backstop for parked seams

the park destructs last, after the map lock and the root, so a seam whose destructor re-enters the graph would re-enter a half-destroyed object. Call collect() before destroying the graph for any such seam.

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

struct vertex_policy_t

Everything the OWNER declares about one vertex, as ONE aggregate (RFC-0028 §4.12, D12) — what graph_t::register_vertex and graph_t::set_policy take, in place of the per-vertex set_* wiring verbs slice 10 deleted.

Owner-side, host-only, with no wire surface: no peer can read or write any of it, and nothing is inherited (RFC-0022 §3.F). Every member defaults to what an undeclared vertex does, so vertex_policy_t{} is the default vertex and a caller names only what differs:

const auto v = g.register_vertex(path, role_t::STREAM, {},
                                 {.retention = retention_t::N, .depth = 64,
                                  .ring_source = &ring_pool, .ring_reliable = true});

A policy is stated WHOLE: graph_t::set_policy applies every member, so a member left at its default resets that property. Applying a member that already holds costs nothing — in particular a default policy on a fresh vertex allocates no extension block.

Public Members

std::optional<retention_t> retention = {}

What the vertex retains after a write is delivered (RFC-0028 §5.4, D4); unset ⇒ the role’s default (STORED_VALUE → LAST, STREAM → N at depth 1, HANDLER → NONE).

Legal pairings: STORED_VALUE NONE|LAST, STREAM NONE|N, HANDLER NONE. An illegal one answers SCHEMA_NOT_FOUND from the verb that applies the policy.

  • NONE on a STORED_VALUE makes it a pure relay: every write is delivered and released, the write sequence still moves (so await wakes), read answers NOT_FOUND, and assign / propagate refuse with SCHEMA_NOT_FOUND. A direct write whose only subscribers are callbacks draws no block at all.

  • N sets the ring depth (depth); NONE on a STREAM empties and stops the ring.

Switching to NONE drops what is already held. Costs zero bytes: NONE is a bit in the vertex’s flag byte, and the depth lives in the extension block a STREAM already has.

std::uint32_t depth = 1

Ring depth under retention_t::N (0 behaves as 1); ignored otherwise.

std::size_t share_threshold_bytes = kShareThresholdBytes

The copy-or-share threshold in bytes (RFC-0028 §5.3, D3): a written value of at least this many bytes is SHARED, one below it is COPIED.

At the terminus, a view-delivered, trailer-less WRITE of at least this size is stored as a refcounted link to the inbound receive segment — no allocation for the bytes, no copy; a smaller one is copied into the value’s own block. 0 shares always; SIZE_MAX copies always. What sharing costs on a POOLED RX backend: the shared value BORROWS a pool slot until it is displaced, so size against live shared values x segment_bytes (see config_t::kShareThresholdBytes for the target-class defaults).

mem::block_source_t *ring_source = nullptr

The receiving STREAM vertex’s own ring source (RFC-0025 §4.6.1 clause 3); null ⇒ the graph’s graph_t::default_ring_source.

A producer never queues; the queue belongs to whoever consumes it, bounded in BYTES by that party’s own source. Each admitted entry reserves its retained width from the source until it retires. Per-injection-point, never a shared pool: one receiver running its source dry must not affect another. Changing it DRAINS the ring (every reservation goes back to the source that served it). Meaningful only on a STREAM.

bool ring_reliable = false

The §4.4 pressure arm for ring_source: false (default) BEST-EFFORT — a refused admission sheds the oldest entry whole, accounts the loss and raises tr::flow::address_shift_gap; true RELIABLE — the admission is refused, nothing is shed, and the LOCAL producer’s write answers BACKPRESSURE.

delivery_mode_t delivery_mode = delivery_mode_t::IF_NEWER

How the vertex participates in an ANCESTOR’s propagate sweep (RFC-0008 §C); default IF_NEWER. Maintains the sweep’s UNCONDITIONAL membership.

app_fields_decl_t app_fields = {}

The vertex’s application property field table (RFC-0010 §A) — owning, or BORROWED from static storage (ADR-0058); empty ⇒ no fields (the closed ENOTTY surface).

Applying a policy REPLACES the table (atomically with respect to concurrent field operations), unless the declaration is the very one already installed — the same borrowed array, or an owning table while one of the same shape stands, in which case stored field values are kept. A borrowed table AND the bytes it points at must outlive the vertex.

struct graph_hooks_t

The graph’s five wiring seams as ONE aggregate (RFC-0028 §4.12, D12) — what graph_t’s constructor and graph_t::set_hooks take, in place of the five configure_* verbs slice 10 deleted.

Every slot is a graph_hook_t, published through a tr::sink_slot_t, so a hot-path reader dispatches from one coherent {fn, ctx} snapshot — never a new fn beside a stale ctx. Every slot defaults to null, which is each seam’s documented default, so graph_hooks_t{} is the un-wired graph and a caller names only what it installs:

tr::graph::graph_t g{pool, {.subject_resolver = {&resolve, &my_acl}}};

CONFIGURATION, not runtime knobs (#1049): install at wiring time, from one thread, before frames flow. Each ctx must outlive every dispatch that can still reach its seam.

Public Members

graph_hook_t<subject_resolver_fn_t> subject_resolver = {}

The ACL enforcement switch (ADR-0018): maps a non-empty caller context to a subject token.

Null (the default) DISABLES enforcement: every operation is allowed and the hot path pays one relaxed load. With a resolver, each gated operation with a NON-EMPTY caller evaluates the target’s effective ACL (own ACEs + inherited, ADR-0020); denial returns PERMISSION_DENIED. The EMPTY caller context is the local-API convention and is trusted without consulting the resolver (#905), so the resolver’s error arm is free to mean DENY. A token equal to tr::graph::kEveryoneSubject is refused at every gate (#908): the wire has one spelling for a subject token.

graph_hook_t<sub_observer_fn_t> subscription_observer = {}

The EXTERNAL subscription observer — fired on every :subscribers[] mutation that arrived over a transport (see sub_event_t for what “external” means).

Fires from the one admission door every subscribe lands in and from the :subscribers[N] clear. evict_link_edges and a local unsubscribe stay silent, by design. Null (the default) costs one relaxed load on the subscribe path.

graph_hook_t<remote_delivery_fn_t> remote_delivery = {}

The sink the producer fan-out hands each REMOTE subscriber’s delivery to (#136, RFC-0004 §D/§E.1) — the transport plane’s seam; tr::net::fwd_router_t’s constructor installs it.

Fires on whatever thread calls write (outside the vertex lock), and on subscribe for a transient-local latch. Null ⇒ remote slots are stored but never deliver.

graph_hook_t<wire_target_fn_t> wire_target = {}

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

Null ⇒ a wire SUBSCRIBER’s PATH child is inert, every pre-RFC-0021 embedder’s behaviour. With one, a subscribe whose target routes through a mount binds the edge to (that mount, the residual below it) (#491).

graph_hook_t<stats_sampler_fn_t> stats_sampler = {}

The NET-PLANE :stats seam sampler (RFC-0010 Amendment 2, #1503); the router installs it.

Null ⇒ the census answers for the graph alone and every router / labels / link spelling answers SCHEMA_NOT_FOUND.

The routed-subscription hold seam (RFC-0014 §4, #1816) — the net plane’s standing-binding refcount; tr::net::transport_vertex_t’s constructor installs it on a build that carries the liveness engine.

Null ⇒ subscriptions hold no link, which is every node without that engine. See link_hold_fn_t for when it fires.

template<class Fn>
struct graph_hook_t

One {fn, ctx} graph seam: a captureless function pointer and the context handed back as its first argument (ADR-0047, #1049).

The shape receiver_slot_t and hook_t already use. A null fn is the uninstalled seam — the graph’s documented default for each one.

Public Members

Fn fn = nullptr

The callback, or null when the seam is not installed.

void *ctx = nullptr

Handed back as fn’s first argument; caller-owned.

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 = hook_t<result_t<vertex_handle_t>(graph_t&, std::vector<std::byte> child_key, const wire::tlv_node_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 (a node read in place over the SPEC’s bytes, valid for the call only — #1829), 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). A hook_t (RFC-0028 D10): its ctx must outlive the graph.

Public Functions

explicit graph_t(mem::block_source_t &src = mem::default_root(), graph_hooks_t hooks = {})

Construct a graph drawing every byte it allocates from the single injected src (#873 phase 1) — the collapsed successor to the four-seam constructor.

What this replaced, and why

Until #873 phase 1 this constructor took four positional, defaulted seams — a std::pmr::memory_resource*, a mem::mem_backend_t*, and two mem::block_source_t* — so a deployer who wanted a bounded node had to know which of four allocation channels each of its bytes travelled on, and injecting all four still did not bound the process because a fifth channel (the un-injected global heap) ran alongside them. ADR-0079 settled the composition and the 2026-08-26 ruling settled the shape: mem::block_source_t is the substrate, and the graph builds the other two vocabularies on top of the one source it is handed. mem_backend_t survives as a wrapper TYPE (mem::source_backend_t), not as an injection seam.

Which bytes flow through after phase 1

ALL of the following, where before they came from four separately-injected places:

  • the graph’s own tables (#1778): the vertex tree, the vertex index, the link index, subscriber edge tables, the seam park, the creation catalog, the identity record, the declaration lists and the propagate-sweep sets — through the table sub-pool (table_source), in core containers;

  • the write-path copy-store’s owned value view::segment_t and both folded READs’ exactly-sized POINT headers (ADR-0060, #831) — through an internally-built mem::source_backend_t;

  • every #551 FAILABLE allocation a peer can provoke: vertex registration, the branch-write decode’s bump upstream, the composed read’s collect stack (control_source);

  • the graph-level DEFAULT receiver-ring admissions of a STREAM vertex that has declared no source of its own (default_ring_source);

  • every segment the graph’s READ-BACK encoders mint (#873 phase 3) — :point, :settings, :settings.app, :acl, :children, the identity record, the stats block, an app field’s stored bytes, and the subscriber / mount-route records subscribe composes. These reached tr::view::over_bytes’s single-argument, global-heap overload until phase 3 pointed them at value_backend, so a peer’s READ is now charged to the deployer’s slab like every other channel.

Which bytes do NOT, and where that is tracked

Two carve-outs, both measured or documented rather than pending:

  • the LKV hazard-slot nodes (lkv_slot.hpp) stay new (std::nothrow) on the global heap. #873 phase 2 built the migration, measured it, and reverted it: +22.7 % on hazard-node acquisition and +3.5 % on the free-list-hit steady arm, with disjoint ranges against an A/A null band of −0.12 %. The seam’s own @note carries the figures.

  • the read-back encoders’ STAGING buffers, pinned by tr::wire::emit_tlv’s std::vector<std::byte>& sink. Phase 3 moved the resulting SEGMENT onto the injection; the transient buffer it is copied from is still the global heap’s. The public signatures that still take or return owning std types are the same kind of residual, and move with the public-API batch of the seam migration.

Failure convention

The substrate speaks raw nullptr-on-exhaustion and the graph does not wrap it. The one adapter translates at its own boundary and nowhere else: the backend adapter turns a refusal into a null view::segment_t, which is the BACKPRESSURE signal the write path already answered. The graph’s own tables answer BACKPRESSURE directly. So an injected src bounds the node, and a peer’s CREATE frame can no longer reboot a -fno-exceptions node through the failable channels.

NARROW vs WIDE is WHICH source, never a config knob

A host that passes nothing gets mem::default_root (ADR-0083 Decision 4, #1777). Where kSlabPool is true that is the host root (mem_slab_pool.hpp), and the graph DERIVES its sub-pools from it: values (published values, ring admissions and every segment value_backend mints, through mem::heap_backend) from the value sub-pool, and registration and container blocks from the table sub-pool. The platform allocator then sees whole slabs only. Where it is false the default is the platform heap, as before #1777. A bounded node injects a mem::pool_source_t (or a mem::bump_source_t over null_source()) and the slab’s size IS the bound (ADR-0079): an injected root serves every purpose itself, and no sub-pool is derived from it (derives_sub_pools). No default_config_t option expresses the bound and none will: the divergence is the injected object.

Per-domain overrides still exist, at the seams that own the resource

One injection is the DEFAULT, not a mandate that everything share a store. A STREAM vertex that must not be affected by another receiver’s exhaustion declares its own ring source through vertex_policy_t::ring_source (receiver-pays, RFC-0025 §4.6.1 clause 3) — that seam is untouched, and per-vertex isolation stays a tested property.

Warning

A must not outlive the graph it was read from. This is the one contract the collapse tightens, and it is stated rather than discovered: a stored LKV is a value_t block drawn from the graph’s source, so the handle’s last release hands the block back to that source when the last reference drops. Before the collapse that resource was HOST-owned and the host could keep it alive past the graph; now it is a graph member, so a handle released after ~graph_t calls a destroyed object. The graph’s own members are safe by construction — the two adapters are declared FIRST, so they are destroyed LAST, after the vertex tree that holds every stored LKV — but a handle the application copied out is the application’s to drop first. Every vertex_handle_t obtained from the graph already dangles at that point, so nothing in the reference API is meant to outlive it.

Parameters:
  • hooks – The graph’s wiring seams (graph_hooks_t), installed before the constructor returns. Default: none — ACL enforcement off, no observer, no transport plane. A router constructed later installs its three through set_hooks.

  • src – The one nothrow failable block source every allocation above draws from. Host-owned; it MUST outlive the graph and every value handle obtained from it. An injected source must be thread-safe on a target where a value segment’s reclaim can self-route onto a reader/subscriber thread concurrent with a writer’s allocation (ADR-0060 §2) — mem::heap_source is; a mem::pool_source_t must be composed with the target’s arch-selected synchronisation. Any mem_backend_t is a source too (RFC-0028 slice 10), so a deployer that injects one slab has one slab.

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 vertex_policy_t::ring_source, is the value sub-pool (value_source), since #1777. Exposed for the same reason control_source is: so a host can name it in a memory census and so the wiring is observable.

inline mem::block_source_t &value_source() const noexcept

Where this graph’s VALUES are drawn from (ADR-0083 Decision 3, #1777): every published value, the default ring admissions, and a router’s warm COMPACT copy.

The value sub-pool of the host root (:stats.mem.values) on a default graph that derives_sub_pools; the injected root otherwise.

inline mem::block_source_t &table_source() const noexcept

Where this graph’s TABLE blocks are drawn from: vertex registration, the control-plane containers and the failable scratch of a composed read or a branch write (:stats.mem.tables). On a default graph of a kSlabPool build, a table sub-pool of the graph’s OWN, derived from the host root’s platform heap (#1778): independent graphs share no class lock and no cache line. The injected root otherwise, as value_source.

void trim_tables() noexcept

Return every fully free slab of this graph’s own table sub-pool to the platform heap, on the caller’s schedule (the graph keeps no timer to do it). A no-op on a graph without one (an injected root, or a build without the slab pool); the process-wide sub-pools are trimmed by tr::mem::host_root().trim().

inline mem::block_source_t &net_source() const noexcept

The NET sub-pool a router or link on this graph defaults to when the application injects nothing (ADR-0083 Q21, :stats.mem.net): mem::net_source on a graph that derives_sub_pools, the injected root otherwise.

The router and transport sources stay separately injectable (receiver-pays); this is only their default, and the census name a monitor reads it by.

inline bool derives_sub_pools() const noexcept

Whether this graph derived its sub-pools from the host default root (#1777): true for a graph built without a source on a kSlabPool build.

An injected root serves every purpose itself, so its census is :stats.mem.control alone, and :stats.mem.values, .tables and .net answer SCHEMA_NOT_FOUND (RFC-0010 Amendment 3: “a node that does not derive a given sub-pool”).

inline mem::mem_backend_t &value_backend() const noexcept

The tr::mem::mem_backend_t every view::segment_t this graph owns is drawn from (#873 phase 3).

mem::heap_backend for a process-default graph, and the graph’s own mem::source_backend_t over the injected source otherwise — the pointer the constructor resolved once. Exposed for the reason control_source is (census and observable wiring) and because the graph’s own read-back encoders, which are free functions in graph.cpp rather than members, need to name it: every segment the graph mints must come from the one injection, not from the global heap that tr::view::over_bytes’s single-argument overload reaches.

vertex_handle_t register_vertex(const path_t &path, role_t role, handlers_t handlers = {}, vertex_policy_t policy = {}, std::span<const payload_right_t> rights = {})

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 = {}, vertex_policy_t policy = {}, std::span<const payload_right_t> rights = {})

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

Parameters:
  • handlers – The vertex’s user seams (handlers_t); every ctx must outlive the registration.

  • policy – The owner’s declarations about the vertex (vertex_policy_t), applied before the handle is returned. A default policy costs nothing. An illegal retention for role answers SCHEMA_NOT_FOUND and registers nothing.

  • rights – OPTIONAL payload-type → required-ACL-right table (RFC-0014 Amendment 2) — the general contract by which a control vertex demands something other than plain WRITE for a given written TLV type. BORROWED for the call: the rows are copied onto the graph, under the same lock that publishes the vertex, so the declaration is in force before the first write can reach it. Empty (the default) ⇒ every write gates on acl_right_t::WRITE, and the vertex carries not one byte for this; the write gate then stops at one relaxed flag-bit test on a word it already holds. A row whose payload_right_t::type equals the written value’s leading TLV type supplies the right demanded instead; an unmatched type (and a value whose leading link cannot be read, e.g. a device-memory link) falls back to WRITE. Rows are scanned in order, first match wins. The refusal is still the ONE write gate’s, counted into delivery_drops_t::denied: this declaration changes WHICH right is demanded, never where the demand is made. (It was handlers_t::payload_rights until RFC-0028 slice 7 took it out of the seam struct.)

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 = {}, vertex_policy_t policy = {}, std::span<const payload_right_t> rights = {}, std::span<const std::byte> schema_catalog = {})

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. handlers, policy and rights are exactly try_register_vertex’s.

Parameters:

schema_catalog – OPTIONAL content of the vertex’s :schema SETTINGS — the catalog a CONTROL vertex declares for what its writes accept (RFC-0014 Amendment 3: the creator endpoint’s POINT{NAME, SETTINGS{…catalog…}}). The bytes are a sequence of encoded TLVs, served VERBATIM and never parsed here; the declaring caller owns their vocabulary and validates writes against it itself. BORROWED for the call: copied onto the graph beside rights, under the same lock and in the same immortal node, so it is in force before the vertex is reachable. Empty (the default) ⇒ the ordinary empty SETTINGS, and a vertex that declares no catalog carries nothing.

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 (forward-only per address, modulo 2^32).

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 roots_ 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_USE – id 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.

The memo was previously declined on a footprint argument — “4 bytes on rv32,

where `sizeof(vertex_t)` sits at its ratchet with zero headroom” — and the #1487 census falsified its premise. There are 4 dead bytes at object offset 36–39 on BOTH ABIs, and spending them costs zero:

sizeof(vertex_t) stays 96 / 72 and both config_t ratchets still pass, pinned to their measurements. The price is a layering compromise, not RAM — the bytes are only reachable from inside path_key_t (they are name_’s tail padding on x86-64), so the memo lives there, documented as borrowed. A pointer→index side map, the other candidate, would still cost strictly more than the 4 B/vertex RFC-0024 §6.4 priced.

What made it worth spending is that the scan was O(N) under the shared — 450 ns at 10³ resident vertices and 410 µs at 10⁶ (#1485/#1496) — so route formation over M bindings paid O(M×N) and every concurrent reader queued behind the hold. The mint is once per binding, but “once per binding” is M times, not once.

Note

This is a control-plane call, but it is no longer priced as a scan (#1486). The reverse direction IS memoized: every vertex carries the index of its own slot, stamped once at slot assignment under the same unique hold that appended it, and this call validates that memo against the index and returns. The scan survives only as the fallback for a vertex_t no graph slotted.

Warning

The memo is INTERNAL. It is not exposed on path_key_t, is not exposed here, and is not a second staleness signal: the generation remains the whole of that (RFC-0024 §5.1). The hot path — deref_vertex_slot — pays a bounds check and one compare and never comes here.

Return values:

std::nullopt – vh'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::nullopt – index 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 roots_, 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_t), 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 (link_index_t’s “DENSE SLOT VECTOR” block spells out why — deliberately NOT an @ref, because that anchor lives on an implementation 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 bool 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 sorted tables), 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 array, all from the graph’s table source, then sorts. Do not put this on a delivery or write path.

Returns:

false when the table source refused the snapshot (#1778): fn was called for nothing. A walk either visits every vertex or none.

void register_child_type(std::string_view 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 graph_hooks_t seams: populate the catalog at setup, before frames flow. Unlike them the catalog is a sorted table, so #1049’s {fn, ctx} publication does not reach it — a concurrent insert moves entries the in-band creation path may be reading. Registration and lookup therefore take a lock, which costs nothing: both are control-plane cold (one 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.

The entry draws from the graph’s table source (#1778). Setup is where the catalog is sized, so a refusal here is a sizing bug: it aborts with a message naming the source and the bytes it was asked for (ADR-0056, ADR-0083).

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 value_ref_t (RFC-0028 D11, one read type): a reference to the published block, not a copy. A scalar is the single-link case; a consumer needing contiguous bytes calls value_t::only() (single-link, zero copy) or value_t::materialize(), and one that needs a rope_t clones it with value_t::rope(). 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.

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_t→rope_t. caller is the ACL caller context (see read).

link is the transport-catalog (kind, role) of the link the write arrived on, or null for a write that arrived over none (#1650). It gates nothing here: it reaches the vertex’s admission filter and handler as write_ctx_t::link, beside caller as write_ctx_t::subject. The router passes it for a write a link carried; an API write leaves it null.

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. link is as for the plain overload: an empty field hands it to handlers_t::on_admit and on_write, and a :settings.app.<name> field write hands it, with caller, to handlers_t::on_app_field_admit in the same write_ctx_t (#1832).

result_t<void> assign(vertex_handle_t v, view::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_FOUND – v'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.

retention_t retention(vertex_handle_t v) const noexcept

What v retains (RFC-0028 §5.4): its role’s default unless its vertex_policy_t declared otherwise.

result_t<void> set_policy(vertex_handle_t v, vertex_policy_t policy)

Apply policy to v WHOLE (RFC-0028 §4.12, D12) — the one owner-side wiring verb that replaced set_retention, set_share_threshold_bytes, set_ring_source, set_delivery_mode, set_app_fields and set_app_fields_static.

Every member of vertex_policy_t is applied, so a member left at its default resets that property; a member that already holds is skipped, so re-applying a policy costs nothing and a default policy on a fresh vertex allocates nothing. A wiring-time call, like register_vertex (the “configure before frames flow” contract): changing the ring source drains the ring, switching retention to NONE drops what is held, and re-declaring an owning field table resets its values to the declared ones.

Returns:

SCHEMA_NOT_FOUND when the policy’s retention is illegal for v's role (a HANDLER asked to retain, a STORED_VALUE asked for a ring, a STREAM asked for LAST) — checked before anything is applied, so a refused policy changes nothing.

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.

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<void> arm_await(vertex_handle_t v, await_waiter_t &w, std::string_view caller = {})

The non-blocking form of await (ADR-0084): gate caller for READ, then arm the one-shot waiter w on v and return at once.

w fires on the next change of v, on the writer’s thread, after the publish has landed; the callee then serves the value with await_value. The graph allocates nothing: w is the caller’s, and must stay alive until it fires or disarm_await returns true for it. There is no deadline here — a timeout is the caller’s to run, by calling disarm_await.

Returns:

PERMISSION_DENIED when the READ gate refuses (nothing is armed).

result_t<value_ref_t> await_value(vertex_handle_t v) const

Serve a woken await’s value: the role dispatch await runs after its wake, shared so the blocking and the armed forms answer alike.

The READ gate is the caller’s to have checked (arm_await checks it).

Returns:

The value, or NOT_FOUND (never assigned / a handler with no on_read).

result_t<value_ref_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 SAME reference read hands back, no copy; otherwise serve :schema, :acl, :children[] (the folded listing) or a single :subscribers[N] slot (the slot’s stored SUBSCRIBER view, zero-copy). For the whole-array :subscribers[] read use read_subscribers. Used by the FWD resolver.

One read type (RFC-0028 D11): a field answer is a value_ref_t like every other read. A field value is COMPOSED — nothing published it — so it costs the one block value_ref_t::composed draws from the heap; an empty field costs nothing.

result_t<std::vector<view::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::size_t> history(vertex_handle_t v, std::span<value_ref_t> out) const

Stream history into caller storage, oldest first (Stream role only) — RFC-0028 D11.

Fills out with the NEWEST min(out.size(), retained) ring entries, oldest first, each a value_ref_t share of the retained block: one refcount bump per entry, NO allocation and no byte copy. A span as long as the vertex’s retention_t::N depth always holds the whole ring. Entries past the returned count are left untouched.

Return values:
  • status_t::SCHEMA_NOT_FOUND – v is not a STREAM.

  • status_t::PERMISSION_DENIED – The local caller lacks READ.

Returns:

The number of entries written to out.

result_t<std::size_t> drain_unflushed(vertex_handle_t v, std::vector<value_ref_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_FOUND – v 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_policy carry.

Return values:

status_t::SCHEMA_NOT_FOUND – v is not a STREAM.

result_t<value_ref_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 link chain (an outer POINT header link plus the member links) instead of one flat buffer, answered as a value_ref_t (RFC-0028 D11: every value read answers one type).

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 value 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 value. The composed value is one block from the global heap (value_ref_t::composed), whose refusal is BACKPRESSURE. 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<value_ref_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 value; 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<value_ref_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 link chain of views over the live last-known values (zero flatten, zero byte copies), answered as one composed value_ref_t (RFC-0028 D11).

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.

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_MISMATCH – kind 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_hooks(const graph_hooks_t &hooks) noexcept

Replace the graph’s five wiring seams WHOLE (RFC-0028 §4.12, D12) — the one verb that replaced the five configure_* verbs.

The constructor takes the application’s hooks; this verb exists for the party that can only be built AFTER the graph — tr::net::fwd_router_t, whose constructor takes the graph and installs its three transport-plane seams (remote_delivery, wire_target, stats_sampler) by reading hooks, filling its slots and handing the whole struct back, so the application’s two stay as they were.

CONFIGURATION, not a runtime knob (#1049): from one thread, before frames flow. Each slot is published through its own tr::sink_slot_t, so a dispatch racing the install sees a whole new pair, a whole old one, or none — never a new fn beside a stale ctx; what it does NOT do is stop a dispatch already in flight, so every ctx must outlive every dispatch that can still reach its seam.

graph_hooks_t hooks() const noexcept

The five wiring seams as currently installed — the read half of set_hooks (one coherent snapshot per slot).

bool sample_stats(std::string_view seam_class, std::string_view seam_name, stats_block_t *out) const noexcept

Ask the installed sampler for one net-plane seam — the read side of graph_hooks_t::stats_sampler.

Public because the census encoder is a free function over graph_t’s public accessors (it needs no friendship and mints no other symbol), and useful on its own to an in-process supervisor that wants one seam’s block without going through the wire door.

Parameters:
  • seam_class – The seam CLASS (router, labels, link).

  • seam_name – The seam NAME within it.

  • out – Where to write the block, or nullptr to probe recognition only.

Returns:

false when no sampler is installed or the spelling names no seam.

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, view::rope_t value)

Write by path — resolve the key once, then write(vertex_handle_t, view::rope_t,std::string_view, const net::link_kind_t*).

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::size_t share_threshold_bytes(vertex_handle_t v) const noexcept

v's copy-or-share threshold in bytes (RFC-0028 §5.3): what it declared with vertex_policy_t::share_threshold_bytes, else tr::graph::config_t::kShareThresholdBytes.

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).

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_FOUND – vh 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.

Compiled in only when default_config_t::kInstrumentCounters is bound true; on a lean build (the default) the counter does not exist and this answers 0 (#1664).

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.

Compiled in only when default_config_t::kInstrumentCounters is bound true; on a lean build (the default) the counter does not exist and this answers 0 (#1664).

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 bool disarm_await(await_waiter_t &w) noexcept

Take back an armed waiter that has not fired (a timeout or a teardown).

Return values:
  • true – It had not fired, and it never will: it is the caller’s again.

  • false – It already fired (or is firing) on a writer’s thread.

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, tr::mem::block_source_t &src = tr::mem::table_source())

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, from src, only if this identity needs one (#361 §1).

The graph builds every vertex as a handler-less placeholder (no allocation) and installs an identity through fill, whose refusal is a value. A standalone vertex built WITH handlers whose src refuses the extension block stops the node (mem::exhausted_at_init): a constructor has no other way to answer.

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.

A RELAXED atomic load (#1477). The read is lock-free and on the write hot path, while the writers (fill, revert_to_placeholder) run under the graph’s unique map lock — so the load is unordered by construction and may observe either the retiring occupant’s role or the placeholder default. What the atomic buys is that the race is WELL-DEFINED rather than UB; it does NOT serialise a write against a retire (see the doctrine note at graph_t::write).

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 try_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::size_t share_threshold_bytes() const noexcept

This vertex’s copy-or-share threshold in bytes (RFC-0028 §5.3): config_t::kShareThresholdBytes unless it declared its own.

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 threshold answers the build’s default, 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 app_field_write_hook_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). Two words; copying it allocates nothing. 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 void arm_waiter(await_waiter_t &w) noexcept

Arm the one-shot waiter w on this vertex (ADR-0084): it fires on the next change, on the writer’s thread, instead of blocking the caller.

The non-blocking twin of wait_for_change, and the same half of the Dekker pair documented on store. The waiter count is raised (seq_cst) under the stripe mutex before this returns, so a publish ordered after the arm takes the slow path and finds w, and a publish ordered before it is a change that preceded the wait — exactly what wait_for_change’s snapshot treats as “before”.

Parameters:

w – An unarmed waiter whose fire is set; it must stay alive until it fires or disarm_waiter returns true for it.

inline value_ref_t read_stored() const

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

inline bool wait_for_change(write_seq_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: an equality test, so a wrap between the snapshot and the check is still a change); false on timeout.

inline write_seq_t current_seq() const

The current write sequence (bumped per assign — the await predicate base). 32-bit and wrapping (write_seq_t): compare it for equality only.

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<value_ref_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::size_t history_into(std::span<value_ref_t> out)

Copy the NEWEST min(out.size(), ring count) STREAM ring entries into out, oldest first — each a value_ref_t share of the entry’s block (one refcount bump, no byte copy, no allocation; RFC-0028 D11).

Returns:

The number of entries written; out[0, n) holds them, the rest is untouched.

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, tr::mem::block_source_t &tables = tr::mem::table_source())

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, remote_ptr_t *retired_remote = 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.

  • retired_remote – Optional out-parameter receiving the cleared edge’s cold remote half (#1816) — moved out under the lock, so the caller can name the link the edge was routed through after releasing it. Same write rule as retired_ctx.

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, remote_ptr_t *displaced_remote = 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.

  • displaced_remote – Optional out-parameter receiving the displaced edge’s cold remote half (#1816), moved out under the lock. A cleared slot holds none, so it stays empty unless a live remote edge was displaced.

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.

Parameters:

routed – Incremented once per evicted edge that was ROUTED through link — stored it as its delivery link rather than only as the gate context — which is the count of link holds the eviction gives back (#1816).

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::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::view_t> edge_sources()

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

inline void clear_declarations() noexcept

Take back everything a registration DECLARES, to an unregistered placeholder’s default: the policy members (retention by role with no RETAIN_NONE, depth 1, no ring so no ring source and best-effort, the default share threshold, no app-field group) and the payload-right and admission flags.

The flags belong to the occupant, not to the address (RFC-0014 Amendment 2): the graph’s rows and filter nodes for this vertex stay parked and unreachable while the bits are clear, and a re-registration that declares again prepends its own, newer node.

Retirement calls it, and so does a registration refused after its declarations landed on the placeholder (#1778), so a later registration through a door that brings no policy (the write-create ensure_vertex) inherits none of it. The caller MUST hold the graph map lock; the stripe lock is taken here. Allocates nothing.

inline value_handlers_t *revert_to_placeholder(tr::mem::block_array_t<subscriber_t> &gone)

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_ (forward-only per address, mod 2^32; a reset would hand a live await snapshot back its own value), 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.

Parameters:

gone – Receives this vertex’s whole subscriber slot table (MOVED out, never copied, so it cannot fail): the graph gives each ROUTED edge’s link hold back and drops the table once its locks are released (#1816, #1778). Left as it was when this vertex has no edge block.

Returns:

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

inline bool set_acl(std::vector<ace_t> aces, tr::mem::block_source_t &tables)

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:
  • rebuild – std::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 bool set_app_fields(std::vector<app_field_t> table, tr::mem::block_source_t &tables)

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 bool set_app_fields_static(borrowed_fields_t table, tr::mem::block_source_t &tables)

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 vertex_policy_t::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 result_t<void> 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) — or store nothing, if the field retains nothing (wo, or declared retention_t::NONE; RFC-0028 §5.4). The caller’s apply seam fires either way.

Returns:

SCHEMA_NOT_FOUND iff name is not declared (e.g. a concurrent table replacement removed it between the caller’s gate and this store), and BACKPRESSURE when the table source could not hold the bytes (#1778) — the field keeps its previous bytes.

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 bool set_retention(retention_t r, std::uint32_t depth, tr::mem::block_source_t &tables)

Declare what this vertex retains (RFC-0028 §5.4, D4) — owner-side, never over the wire. The role/retention pairing is graph_t::set_retention’s to validate.

  • retention_t::NONE sets the one flag bit the write path tests, and DROPS whatever is already held — the last-known value and every queued ring entry (their reservations back to the source that served them) — so read answers NOT_FOUND from the call on. Zero bytes: the bit lives in the flag byte vertex_t already has.

  • retention_t::LAST clears the bit.

  • retention_t::N clears the bit and records depth, allocating the extension block if this vertex has none (a STREAM vertex always has one already), under the vertex mutex the ring append re-reads it under — so a depth change and a concurrent append cannot interleave halfway. The next append trims to it.

Wiring-time, like set_ring_source — a store racing a switch to NONE may land the one value the switch was meant to drop.

Parameters:
  • r – The retention.

  • depth – Entries to retain under retention_t::N; 0 is normalised to 1 by the ring trim. Ignored otherwise.

inline bool retains_none() const noexcept

True iff this value vertex was declared retention_t::NONE — the write path’s one relaxed test of the flag byte it already reads for admission. A HANDLER retains nothing by role and does not carry the bit.

inline retention_t retention() const noexcept

What this vertex retains: NONE for a HANDLER or a vertex declared so, N for a STREAM (its ring), else LAST.

inline std::uint32_t retention_depth() const noexcept

The ring depth a STREAM retains under retention_t::N (1 when never declared).

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

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_retention 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 bool ring_reliable() const noexcept

This receiver’s §4.4 arm: true once declared RELIABLE (see set_ring_source); false by default.

inline std::span<const app_field_slot_t> app_field_slots() const

The installed app-field slots (empty when none) — what graph_t::set_policy compares a borrowed declaration against, so re-applying it keeps the values.

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 bool set_share_threshold_bytes(std::size_t bytes, tr::mem::block_source_t &tables)

Set this vertex’s copy-or-share threshold (RFC-0028 §5.3) — owner-side, never over the wire. 0 shares always; SIZE_MAX (or anything from UINT32_MAX up) copies always.

Published under the vertex mutex; the write-path reader (share_threshold_bytes) takes no lock, because a threshold 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 every build 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 Functions

static inline bool disarm_waiter(await_waiter_t &w) noexcept

Unlink w if it has not fired yet (a timeout or a cancel).

Needs no live vertex: the stripe is derived from the address w recorded.

Return values:
  • true – w was still armed and is now the caller’s; the waiter’s fire callback will never be called for it.

  • false – A publish already took it: its fire runs (or ran) on the writer’s thread.

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.

class ring_take_t

The STACK-FIRST buffer a STREAM ring’s unflushed window is taken into (#1713): the first kInline entries live in the caller’s frame, a wider window spills once to the heap.

The write path fills it in the SAME stripe-lock section that admits the entry (ring_admit’s take out-param), and the sweep path through take_unflushed, so a STREAM write is one lock section and — in the common case, where the window is the write’s own entry — no allocation at all. The heap std::vector it replaces cost a malloc/free per write on a host, and two pairs on a -fno-exceptions target, where the nothrow reserve probes before it commits.

It is transient caller storage, never a library-held buffer: it lives for one delivery and holds refcount shares of entries the ring already owns. The spill keeps the old drain’s contract exactly — a window that cannot be snapshotted is NOT taken, so the cursor stays put and the next covering flush re-takes it (deferred, never lost, #477).

Public Functions

inline explicit ring_take_t(tr::mem::block_source_t &src) noexcept

An empty take whose spill, if one is ever needed, draws from src (the graph’s value source, #1778); nothing allocated.

ring_take_t(const ring_take_t&) = delete

Non-copyable — transient delivery storage, never a value.

ring_take_t &operator=(const ring_take_t&) = delete

Non-assignable — transient delivery storage, never a value.

inline std::span<const value_ref_t> entries() const noexcept

The taken entries, oldest first.

inline bool engaged() const noexcept

Did a ring admission run the take at all? False only when the store never reached a STREAM ring (a role that changed under a racing retire), which is the one case the caller falls back to a separate drain for.

Public Static Attributes

static constexpr std::size_t kInline = 4

The in-frame width: 4 entries — 32 B on a 64-bit host, 16 B on rv32.

A STREAM write’s window is its OWN entry (the fused take empties the cursor every write, so a concurrent writer takes its own entries too); a wider window only comes from assigns queued without a flush. Four covers a write behind a short burst of those, and the slots cost less than one edge_view_t of frame. A strategy knob, not a limit: a wider window spills and is delivered whole (STYLE.md, counting doctrine 6).

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 store — a declined slot, ring or clone).

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.

enum class tr::graph::emission_mode_t : std::uint8_t

How a graph_t::propagate sweep EMITS what it selected (RFC-0025 §4.1.2, Amendment 3 clause 5) — a producer-side choice about framing, never a subscription negotiation.

Orthogonal to delivery_mode_t — that mode decides WHICH vertices a sweep selects, this one decides how the selection reaches the wire. Neither is a per-subscriber knob, and neither is readable or writable by a peer — the producer owns cadence and framing (RFC-0005 §Motivation-3, RFC-0025 §3).

Values:

enumerator PER_VERTEX

The DEFAULT, unchanged: one FWD{WRITE} per selected vertex (RFC-0008 §D).

enumerator FOLD

ONE branch-write frame for the swept subtree — the RFC-0016 POINT tree, node shape byte-for-byte RFC-0005 §B’s, root carrying its leading NAME. One frame per SUBTREE and never a container across several (the retired-LIST ban, RFC-0005 §E).

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 explicit edge_block_t(mem::block_source_t &src) noexcept

An empty block drawing from src.

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

mem::block_array_t<subscriber_t> slots

The master slot table (stripe-locked). Its source served this block and serves every published array too (#1778).

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.

The entries are an intrusive list of their own reservations (ring_entry_t): a push or a pop is a pointer swap, and the ring allocates nothing beyond what it charges.

Public Functions

inline void push_back(ring_entry_t *n) noexcept

Link n in as the newest entry.

inline ring_entry_t *pop_front() noexcept

Unlink and return the oldest entry (the ring must not be empty). Its block is still reserved: the caller releases or reuses it.

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. A non-empty ring always has a bound source: an entry exists only once a source served it.

inline ~ring_state_t()

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

Public Members

ring_entry_t *head = nullptr

The oldest queued entry, or null when the ring is empty.

ring_entry_t *tail = nullptr

The newest queued entry, or null when the ring is empty.

std::size_t count = 0

How many entries are queued.

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. This declaration IS the ruled selector: RFC-0025’s 2026-08-24 §4.4 selector erratum (#1204) makes the receiving vertex’s own arm the one §4.4 binds, and demotes the subscription’s reliability bits to carried-verbatim-read-by-nothing. Receiver-pays (Amendment 2, §4.6.1): the party that funds the ring’s bytes declares what its overflow means.

struct ring_entry_t

One entry of a receiving vertex’s STREAM ring: the value, LIVING IN 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.

The entry is placed at the FRONT of that block (RFC-0028 slice 6): the ring is an intrusive doubly-linked list of its own reservations, so the queue’s bookkeeping draws from the same injected source the byte bound charges and from nowhere else. The std::deque this replaced put its ~512 B map node and its chunks on the global heap, where no injected source ever saw them. kRingEntryOverhead is sizeof this struct, so every reservation is wide enough to hold it.

It does NOT bound PLACEMENT of the payload. The payload never moves: value stays exactly the value_t block the publish minted, its links in whatever backend gave them, 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 payload bytes physically move into the injected source will be wrong, and the wrongness is expensive.

Public Members

value_ref_t value

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

std::size_t bytes = 0

The reserved width, as passed to try_alloc — required to release the block this entry lives in (the sized-reclaim contract).

ring_entry_t *prev = nullptr

The next-older entry, or null at the head (the oldest).

ring_entry_t *next = nullptr

The next-newer entry, or null at the tail (the newest).

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

No target may weaken the delivery-skip pair (#1143, #1717).

The precondition #1140 could previously only state in prose, now compiler-checked: on a target that reorders a later relaxed load ahead of an earlier seq_cst store — every shipped MCU target, and any many-core aarch64 host — the skip gate’s two halves must share one total order, which nothing weaker than seq_cst gives them. Ablating kDeliverySkipOrder fails the BUILD instead of passing the suite on a TSO host and shipping a lost delivery to the targets CI cannot run.

The assertion is unconditional. Until #1717 a build could set kWeaklyOrdered = false to claim a TSO target and waive it; nothing in-tree did, and the order is seq_cst everywhere, so the waiver bought nothing a build could use and the trait was removed.

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 as retention_t::N by graph_t::set_retention (RFC-0028 §5.4; RFC-0022 §3.C).

enumerator HANDLER

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

enum class tr::graph::retention_t : std::uint8_t

What a vertex — or one of its application fields — RETAINS after a write is delivered (RFC-0028 §5.4, D4): one property with one spelling, where there used to be three (a role rule, a depth verb, and a separate store for wo fields).

holder

default

legal

HANDLER vertex

NONE

NONE — the handler consumes the value

STORED_VALUE vertex

LAST

NONE, LAST

STREAM vertex

N (depth 1)

NONE, N with a depth

app field ro / rw

LAST

NONE, LAST (N reads as LAST — a field holds one value)

| | app field wo | NONE | NONE — a write-only field has no read surface, so it stores nothing |

NONE on a value vertex is the pure-relay shape: the write is delivered to every subscriber and released, the write sequence still moves (so await wakes), and read answers NOT_FOUND. It is a permitted policy, not a named role (RFC-0028 §11 ruling 3). Owner-side and host-only: no peer reads or writes it.

Values:

enumerator NONE

Deliver and release; keep nothing (read ⇒ NOT_FOUND).

enumerator LAST

Keep the last-known value — one slot, displaced by the next write.

enumerator N

Keep the last N entries in the receiving vertex’s ring (RFC-0025 §4.6.1): the depth is the INTENT, the ring source’s bytes the BOUND.

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 policy — tr::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::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::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::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, guarded binding included (a core with no atomic RMW, #1722).

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 value_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 published block it is (value_t, RFC-0028 §5.1) — no wrapper, no copy; the sink reads its links in place and may clone them (value_t::rope, refcount bumps) if it keeps them past the call.

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_hooks_t::remote_delivery) 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::view_t return_route

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

view::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_hooks_t::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_hooks_t::remote_delivery.

The :stats census seam (RFC-0010 Amendments 1–2)

struct stats_counter_t

One member of a :stats census block — a noun and its value (RFC-0010 Am. 2).

The noun is BORROWED and must be a literal (or otherwise outlive the sampling call): the block is encoded before the sampler’s frame is left, and nothing copies the string.

Public Members

std::string_view noun = {}

The core/STYLE.md §Introspection vocabulary name.

std::uint64_t value = 0

Its value, emitted as a fixed-width u64.

struct stats_block_t

One sampled seam block, filled by a stats_sampler_fn_t (RFC-0010 Am. 2).

A fixed-capacity, allocation-free carrier: the net plane samples INTO it and L4 encodes it, so the whole census keeps ONE encoder and one wire shape (RFC-0010 Am. 1 §D.3), and the sampling half never touches an allocator on a path a peer can drive.

kMaxMembers is a compile-time ceiling on how many nouns one seam may publish, not a protocol limit: it is sized to the widest block the reference net plane serves (router_stats_t’s seven) with headroom, and a seam that outgrew it would be split rather than truncated — add refuses silently past the ceiling exactly so a caller cannot emit a half-member.

Public Functions

inline void add(std::string_view noun, std::uint64_t value) noexcept

Append one member; a no-op once kMaxMembers is reached.

Parameters:
  • noun – Borrowed, and must outlive the sampling call.

  • value – The counter’s value.

Public Members

std::array<stats_counter_t, kMaxMembers> members = {}

The filled prefix.

std::size_t count = 0

How much of it is filled.

Public Static Attributes

static constexpr std::size_t kMaxMembers = 12

The per-seam member ceiling — see the class brief.

using tr::graph::stats_sampler_fn_t = bool (*)(void *ctx, std::string_view seam_class, std::string_view seam_name, stats_block_t *out)

Sample one NET-PLANE :stats seam — the sixth {fn, ctx} seam the router installs UP into the graph (RFC-0010 Amendment 2, #1503 residual).

Amendment 1 §D.4 drew the census boundary at the graph, because L4 cannot reach DOWN into the net plane to sample a router or a link. This seam inverts the direction instead of the dependency: the router, which already knows the graph, registers a sampler UP — the same shape as graph_hooks_t::remote_delivery and the four resolver seams its constructor installs — so L4 still names nothing below it.

Note

Called ONLY from the cold :stats read path, never on a hot path. It MUST NOT re-enter graph_t, and ctx must outlive every read the graph can still serve — the lifetime the router’s other five seams already require.

Param ctx:

The caller-owned context installed beside the function.

Param seam_class:

The seam CLASS — router, labels or link (Amendment 2 §D.4).

Param seam_name:

The seam NAME within that class; for link it is the router’s child_registry_t name.

Param out:

Where to write the block, or nullptr for a RECOGNITION PROBE: answer whether the spelling names a seam and sample nothing.

Return:

true when the spelling names a seam this sampler serves. false is the node’s “this seam is not published here” — SCHEMA_NOT_FOUND, and per Amendment 1 §Compatibility a monitor MUST read it that way, never as an error.

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.

const net::link_kind_t *link = nullptr

The transport-catalog (kind, role) of the LINK this write arrived on (#1650); null when it arrived over none.

subject says WHO wrote; this says over WHAT. A policy that must treat a session a ws listener accepted differently from a peer link this node dialled — the same write, the same vertex — tests link->is("ws", net::conn_role_t::LISTEN) rather than inferring the kind from a link name. It is fixed once per link at registration and costs a frame one pointer, read from the router’s per-link context the subject is derived from.

NULL means no catalogued link carried THIS write, which is three cases a filter tells apart through the subject (subject):

  • the owner’s own API write (is_local_owner);

  • a delivery landing here from a SUBSCRIPTION EDGE (a fan-in write). Such a write runs under the edge’s stored subject, which was gated when the edge was admitted; the edge does not carry its creator’s link kind (the edge record does not grow for it);

  • a write over a link registered without a catalog identity (a link added to the router directly rather than through transport_vertex_t).

A filter whose policy depends on the link kind therefore decides the null case explicitly for a non-owner subject, rather than reading null as either kind.

Warning

BORROWED for the call, like the subject — copy the pair out if the decision must be remembered past the return.

template<class Sig>
struct hook_t

THE graph callback idiom (RFC-0028 D10): a {fn, ctx} pair over the call signature Sig. Declared only; the R(A...) specialisation below is the definition.

template<class R, class ...A>
struct hook_t<R(A...)>

A {fn, ctx} callback slot for the signature R(A...).

An aggregate: {fn, ctx} builds one, and a captureless lambda whose first parameter is void* converts to fn_t implicitly. Default-constructed it is EMPTY (fn == nullptr), which every seam treats as “not installed”. Trivially copyable — copying a hook copies two words and never allocates.

Public Types

using fn_t = R (*)(void *ctx, A... args)

The function half: the caller’s context first, then the seam’s arguments.

Public Functions

inline explicit operator bool() const noexcept

True iff a callback is installed.

inline R operator()(A... args) const

Call the installed callback. Precondition: *this is non-empty.

Public Members

fn_t fn = nullptr

The callback, or null when the seam is not installed.

void *ctx = nullptr

Handed back as fn’s first argument; caller-owned.

template<class F>
thunk_t<F> tr::graph::thunk(F &f) noexcept

Point a hook_t at a callable (RFC-0028 D10’s tr::graph::thunk<F>).

The hook stores &f, so f must outlive every call made through it. For a seam that only needs an object you already own (this), prefer the two-word aggregate {captureless_lambda, this}: nothing extra to keep alive.

A hook is a STORED callback whose context the caller keeps alive. A callback that is only called before the function taking it returns takes the non-owning function_ref_t instead (ADR-0083 Decision 9, #1776): two words, no allocation, and it binds to a temporary lambda.

template<class Sig>
class function_ref_t

Primary template; only the function-type specialization is defined.

template<class R, class ...Args>
class function_ref_t<R(Args...)>

A non-owning, trivially copyable reference to a callable of signature R(Args...).

Holds the callable’s address and a trampoline; a function pointer is held by value. It owns nothing, so the referenced callable must outlive every call made through it. That is always true for a parameter (a temporary lives until the end of the full expression that holds the call) and is the caller’s burden anywhere else, so do not store one; store a callable with inline storage, or a hook, instead.

Never empty: there is no default constructor and no null state, so a call needs no check.

Template Parameters:
  • R – The return type.

  • Args – The parameter types.

Public Functions

template<class F>
inline constexpr function_ref_t(F &&f) noexcept

Refer to f, which is called as a non-const lvalue (or a const one, when f is const).

template<class Fn>
inline function_ref_t(Fn *f) noexcept

Refer to the function f, held by value. Precondition: f is not null.

template<class F>
function_ref_t &operator=(F&&) = delete

Deleted: assigning a callable would leave this reference pointing at an object that dies at the end of the statement. Assign another function_ref_t.

inline R operator()(Args... args) const

Call the referenced callable.

struct handlers_t

User behavior for a Handler-role vertex — six hook_t seams, 96 B on the host (RFC-0028 D10: one callback idiom).

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. on_read supplies the vertex value as a value_ref_t (RFC-0028 D11, one read type): a handler that holds a value already (a cached reading, a value it kept with value_ref_t::keep) answers a reference to it at no allocation, and one that computes a scalar mints it with value_ref_t::copy — one block, the bytes inline. The graph hands the reference back from graph_t::read / graph_t::await unchanged; on_write and on_admit receive the written value as the value_t the write path already holds — by reference, with no clone of its links.

Every seam is a {fn, ctx} pair whose ctx the CALLER keeps alive for as long as the vertex is registered (see libtracer/hook.hpp for the two idiomatic spellings and tr::graph::thunk). An empty hook is an uninstalled seam.

The RFC-0014 Amendment 2 payload-right rows are not a seam and are not carried here: they are the trailing rights argument of graph_t::register_vertex and its siblings.

Public Members

hook_t<result_t<value_ref_t>()> on_read

Supplies the vertex value on read, as an owning reference (RFC-0028 D11).

An empty reference on success is read as a refused allocation and answers BACKPRESSURE, the same answer a value_t::make* that returned nullptr deserves.

hook_t<result_t<void>(const value_t &value, const write_ctx_t &ctx)> on_write

Receives the written value and the writer’s write_ctx_t (#375).

Warning

Both arguments are BORROWED for the call. The value is the one the write path holds — for a delivery from a subscription edge, the very block the source published (RFC-0028 D2: a HANDLER target adopts like a stored target, no per-handler copy); for a relay or a local write it may be storage on the writer’s stack. A handler that keeps the value past its return takes value_ref_t::keep(value) — a refcount share of a published block, a copy of the links out of stack storage — and NEVER keeps the reference or its address.

hook_t<result_t<view::view_t>()> on_children

Synthesized :children[] listing.

admit_hook_t on_admit

The ADMISSION seam of a RETAINING vertex: runs BEFORE the write becomes state, and decides whether — and in what form — it does (admission_t).

The gap it closes. on_write is the HANDLER role’s seam, and a HANDLER retains nothing: choosing it to validate a write meant giving up the last-known-value, the await wake and the whole composed-read surface that make a STORED_VALUE the graph-authoritative form of a datum. So a consumer had to pick RETENTION or VALIDATION and could not have both. This seam is the same refusal power on the storing roles, taken at the one place every store goes through (graph_t::store_value) rather than bolted onto one door.

WHERE it runs, exactly: after the write gate’s ACL decision, before vertex_t::store, therefore before the sequence bump, before any await wake, before a STREAM ring admission and before ANY subscriber delivery — local, bubbled or remote. A refused write is unobservable except as the writer’s error; a normalised one is observable ONLY in its normalised form.

WHICH writes it sees: every write that would STORE at this vertex, whatever the door. write and assign alike, a FWD{WRITE} terminus, a delivery landing here from an inbound edge (the fan-in write), and this vertex’s own slice of a branch-POINT decomposition. Admission is a property OF THE VERTEX, not of a channel — a vertex whose invariant only held against remote writers would not hold. The owner’s own writes are included and are told apart by write_ctx_t::is_local_owner, which is what a filter that wants to admit the owner unconditionally tests.

WHICH it does NOT see: a HANDLER-role vertex (on_write is its seam and already has this power — installing both, the role’s on_write runs and this does not), and the app-field plane, which is a different plane with its own seam (on_app_field_admit).

COST. Unset ⇒ nothing: the store path tests one bit of a flags word the write path already holds and never loads the seam block. That bit is the whole per-vertex cost.

Warning

Both arguments are BORROWED for the call — the same contract on_write carries, including value_ref_t::keep for a filter that retains the value. The seam runs on the WRITER’s thread with no vertex lock held, so it may re-enter the graph, and it is on the hot write path: a filter that blocks blocks the writer.

app_field_admit_hook_t on_app_field_admit

The app-field plane’s admission seam (RFC-0010 §A.3), the field-shaped twin of on_admit — runs BEFORE a declared :settings.app.<name> write stores its bytes, and may refuse it or normalise it.

Called with the field’s key (below settings.app.), the written TLV and the writer’s write_ctx_t, after the ACL gate and after the RFC-0010 §A.3 writability check, before app_field_store. The context is the one on_admit receives (#1832): the subject the ACL gate ran on, and the arrival link’s (kind, role) — null for the owner’s own write and for a field write no catalogued link carried. Return the view handed in to store it verbatim (the pre-existing behaviour), a DIFFERENT view to store those bytes instead, or std::unexpected(status) to refuse — in which case nothing is stored, the field keeps its prior bytes, on_app_field_write does NOT fire, and the status is the writer’s answer.

Warning

A returned view is READ during the call that returned it — the store copies the bytes out before returning — so it may point at storage the filter owns, but that storage must outlive the return. The context is BORROWED for the call, as for on_admit. Unset ⇒ bytes store verbatim, as before.

app_field_write_hook_t 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 seams handlers_t carries minus the app-field ones and the admission filter: three hook_t pairs, 48 B on the host.

Split off from the public handlers_t input so a vertex that installs none of the three never allocates this block: it 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_t — adopt_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. The two ADMISSION filters live on the GRAPH, not here, for the reason graph_t::admissions_ states — the same reason the payload-right rows do. Set once at registration (vertex_t::adopt_identity), read lock-free thereafter.

Public Members

hook_t<result_t<value_ref_t>()> on_read

Supplies the vertex value on read — the handlers_t::on_read contract.

hook_t<result_t<void>(const value_t &value, const write_ctx_t &ctx)> on_write

Receives the written value and the writer’s write_ctx_t (#375) — the handlers_t::on_write contract, verbatim.

hook_t<result_t<view::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). priority and delivery_class are stored and read back, awaiting the work that honours them — the honest shape RFC-0022 §3.E chose over moving dead per-vertex fields. reliability is not awaiting anything: RFC-0025’s 2026-08-24 §4.4 selector erratum (#1204) rules the pressure arm to be the RECEIVING vertex’s own declaration (vertex_policy_t::ring_source), so these two bits are carried verbatim and read by nothing — decoded, stored, read back from :subscribers[] and re-emitted unchanged, with no behaviour behind them and none promised.

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_t

One published value: an intrusive refcount, its source, and its link chain, in one block (RFC-0028 §5.1).

Never constructed directly — make draws the block from a block_source_t and places the header and the links in it; the last release destroys the links and hands the block back to that source. The read surface is the read-only half of rope_t’s, so a consumer that used to receive const rope_t& reads a const value_t& the same way.

Public Functions

inline bool is_loaned() const noexcept

Whether this value’s header lives in a loaned receive block (RFC-0028 §6.9).

inline bool is_inline() const noexcept

Whether this value’s bytes live in its own block (the copy arm).

inline std::span<std::byte> inline_bytes() noexcept

The inline bytes, WRITABLE — for the maker to fill between make_inline and publication. Precondition: is_inline.

inline void retain() const noexcept

Take one more reference. Relaxed: a holder retaining already owns one.

inline std::uint32_t use_count() const noexcept

The current reference count (a snapshot; for tests and accounting).

inline mem::block_source_t *source() const noexcept

The source the block is released to, or nullptr for storage the caller owns (value_storage_t).

inline std::size_t block_bytes() const noexcept

The block’s size in bytes: inline_bytes_for its length for an inline value, else bytes_for its link count.

inline std::span<const view::view_t> links() const noexcept

The link chain, in order.

Number of links in the chain.

inline const view::view_t &only() const noexcept

The single link of a one-link value. Precondition: link_count() == 1.

inline std::size_t total_length() const noexcept

Total payload bytes across the chain.

inline bool all_host() const noexcept

True iff every link is in HOST space (CPU-readable).

template<class Fn>
inline void walk(Fn &&fn) const

Visit each link’s byte span in order.

inline view::view_t materialize(mem::mem_backend_t &backend = mem::heap_backend()) const

One contiguous view of the value: the link itself when there is one, else a flattened copy through backend.

inline std::expected<view::view_t, view::flatten_err_t> try_materialize(mem::mem_backend_t &backend = mem::heap_backend()) const

The nothrow twin of materialize — the flatten’s refusal comes back by value.

inline view::view_t flatten(mem::mem_backend_t &backend = mem::heap_backend()) const

One contiguous copy of the whole payload through backend (rope_t::flatten).

inline std::expected<view::view_t, view::flatten_err_t> try_flatten(mem::mem_backend_t &backend = mem::heap_backend()) const

The nothrow twin of flatten — the refusal comes back by value.

inline std::vector<std::span<const std::byte>> to_iovec() const

The links as byte spans, in order — the scatter-gather shape a link’s send takes. Allocates the vector; try_to_iovec is the nothrow spelling.

inline bool try_to_iovec(std::vector<std::span<const std::byte>> &out) const noexcept

Nothrow to_iovec into out (cleared first).

Return values:

false – out could not be reserved; it is left empty.

inline view::rope_t rope() const

A rope_t over this value’s links — one refcount clone per link.

The bridge to every seam that speaks rope_t (egress, decode, a sink that keeps the value past the call). A chain longer than the rope’s inline buffer allocates the rope’s spill; try_rope is the nothrow spelling for a hot leg.

inline bool try_rope(view::rope_t &out) const noexcept

Nothrow rope into out: reserves the chain first, so the appends cannot reallocate.

Out of line: until RFC-0028 slice 4 this was the delivery clone dispatch_edge_target took once per bound edge, and inlining the reserve-then-append loop there cost that pinned symbol ~100 B on the symbol ratchet. A target now ADOPTS the published block, and the adopting store_value takes this clone only where it cannot adopt (a HANDLER target, caller-owned storage) or to show an admission filter the links. One call keeps each of those bodies’ shape; the loop itself is the same either way.

Return values:

false – The chain could not be reserved — out is left as it was.

Public Static Functions

static inline constexpr std::size_t bytes_for(std::size_t links) noexcept

The block size a value with links links occupies — header plus chain.

This is what a publish costs in bytes and what release hands back to the source.

static inline value_t *make(view::rope_t &&links, mem::block_source_t &source) noexcept

Mint a value over links, MOVING the chain out of the rope — no refcount traffic on the links.

One try_alloc on source, nothrow (#477): exhaustion is nullptr and links is left intact, so the caller still owns the chain it offered and can soft-fail.

Parameters:
  • links – The chain to publish. Emptied on success; untouched on nullptr.

  • source –

    The block source the value’s block is drawn from and released to. Must outlive every reference to the value (the “handles do not outlive the

    graph’s memory” contract the injection seam already imposes).

Returns:

The value, holding ONE reference (the caller’s), or nullptr when source refused the block.

static inline value_t *make(std::span<const view::view_t> links, mem::block_source_t &source) noexcept

Mint a value over a COPY of links (one refcount clone per link).

The spelling for a chain the caller does not own — a subview of an inbound frame, another value’s links. Same one-try_alloc, nothrow contract as the rope form.

static inline constexpr std::size_t inline_bytes_for(std::size_t len) noexcept

The block size an INLINE value of len bytes occupies: header, its one link, the embedded segment, and the bytes — laid out by the placement module (tr::mem::inline_block_bytes).

static inline value_t *make_inline(std::size_t len, mem::block_source_t &source) noexcept

Mint an INLINE value of len bytes — the copy arm of the copy-or-share policy (RFC-0028 §5.1 / §5.3): ONE try_alloc of inline_bytes_for(len) on source.

The bytes are UNINITIALISED: the maker fills them through inline_bytes before it publishes the value or hands out a reference (the only window in which a value’s bytes may be written). Nothrow (#477): exhaustion is nullptr.

Returns:

The value, holding ONE reference (the caller’s), or nullptr when source refused the block or len does not fit the header’s count.

static inline value_t *make_copy(std::span<const std::byte> bytes, mem::block_source_t &source) noexcept

make_inline over a copy of bytes — one block, one memcpy.

static inline value_t *inline_owner(const view::view_t &link) noexcept

The inline value whose embedded segment link is the WHOLE of, or nullptr.

The backend identity says the segment lives inside a value block; the full-window test says the link is the value’s bytes and not a subview of them (a subview is a different value, and publishes as a link to this one).

static inline void release(const value_t *v) noexcept

Drop one reference; the last one destroys the links and returns the block to its source. Null-safe.

Acquire-release on the decrement, the canonical intrusive release: the thread that frees sees every write the other holders made before they released.

Public Static Attributes

static constexpr std::size_t kAlign = alignof(view::view_t) > alignof(void*) ? alignof(view::view_t) : alignof(void*)

Alignment every block is drawn and released at — the links’ own.

class value_ref_t

An owning reference to a vertex’s PUBLISHED value — what graph_t::read and graph_t::await hand back, and what every slot policy’s load() returns.

A read of a PUBLISHED value returns a reference to it; a read that COMPOSES a new value (graph_t::read_children_folded and its siblings, a field read) returns a reference to a fresh composed block (tr::graph::value_ref_t::composed) — every value read answers this one type (RFC-0028 D11), a HANDLER’s on_read included. Measured when the rule was drawn, both arms alternating inside one binary on a 24-thread host: median 1.37x aggregate for the reference over a rope copy, and p50 improving most where it hurts most — 2,104 ns to 1,193 ns at sixteen readers on one shared vertex.

Holding one keeps the value’s block alive — and under an injected block_source_t that is a real obligation: the block was drawn from the graph’s source, so an outstanding reference pins it (ADR-0069, deferred reclamation). A must not outlive the graph it was read from.

Public Functions

inline value_ref_t(const value_ref_t &other) noexcept

A copy is one more reference to the same value.

inline value_ref_t(value_ref_t &&other) noexcept

A move takes other's reference and leaves it empty.

inline value_ref_t &operator=(value_ref_t other) noexcept

Copy-and-swap: the old reference is dropped as other dies.

inline void reset() noexcept

Drop the reference, leaving this empty.

inline const value_t &operator*() const noexcept

The referenced value. Undefined if this reference is empty.

inline const value_t *operator->() const noexcept

Member access on the referenced value.

inline const value_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 adopt(value_t *v) noexcept

Adopt a reference the caller already holds (a fresh value_t::make, a slot’s retained load).

static inline value_ref_t share(const value_t *v) noexcept

Take a NEW reference on v (one retain).

static inline value_ref_t composed(view::rope_t &&r) noexcept

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 draws one block from the host value sub-pool (mem::value_source(), #1777) — the composed value has no vertex, so no injected source — which the published path does not pay; measured neutral (1.00x over 30 paired samples), because a subtree walk dominates it.

Returns:

The reference, or an EMPTY one when the heap refused the block (#477).

static inline value_ref_t copy(std::span<const std::byte> bytes, mem::block_source_t &source = mem::value_source()) noexcept

Mint a value over a COPY of bytes — the INLINE arm (value_t::make_copy): one block from source, the bytes in it, no segment of their own.

The spelling a HANDLER’s on_read uses for a value it computes (RFC-0028 D11): a scalar reading costs exactly this one block, where the rope it used to answer cost a segment for the bytes and a block to wrap it.

Returns:

The reference, or an EMPTY one when source refused the block (#477).

static inline value_ref_t keep(const value_t &v, mem::block_source_t &source = mem::value_source()) noexcept

Keep a BORROWED value past the call that lent it — the one way a seam that was handed const value_t& (a subscriber callback, handlers_t::on_write, handlers_t::on_admit) retains it (RFC-0028 D10).

A value drawn from a source (a published block — what a subscription edge delivers) is SHARED: one refcount bump, no allocation. A value with no source is caller-owned storage on the writer’s stack (value_storage_t, the relay and local-handler shape), which dies when the call returns, so its links are cloned into ONE fresh block from source — the payload bytes are never copied, only the links’ references.

Never keep the reference itself, or its address: a stack value’s storage asserts, on the way out, that nothing did.

Returns:

The reference, or an EMPTY one when source refused the block (#477).

Friends

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

Identity: two references to the same block.

inline friend bool operator==(const value_ref_t &a, const value_t *b) noexcept

Identity against a raw value pointer (nullptr tests emptiness).

template<std::size_t N>
class value_storage_t

Caller-owned storage for a value of up to N links that is never published — the shape a branch write delivers a slice it did not store in (RFC-0005).

The value lives in this object, so no source is drawn from and nothing is released: the header’s source is null. It holds the ONE reference the storage owns; a consumer must not keep a reference past the storage’s lifetime (asserted in debug builds).

Public Functions

inline explicit value_storage_t(std::span<const view::view_t> links) noexcept

Build over a copy of links (one refcount clone per link). Precondition: links.size() <= N.

inline explicit value_storage_t(const view::view_t &link) noexcept

Build over one link.

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

Build over a rope’s chain. Precondition: r.link_count() <= N.

inline explicit value_storage_t(view::rope_t &&r) noexcept

Build over a rope’s chain by MOVING its links in — no refcount traffic; r is left empty. Precondition: r.link_count() <= N.

inline const value_t &get() const noexcept

The value.

inline operator const value_t&() const noexcept

The value, by conversion.

class rx_loan_source_t : public tr::mem::block_source_t

The “source” of a value whose header lives in a loaned receive block (RFC-0028 §6.9, #1626) — it serves nothing and reclaims nothing.

A loaned value’s header sits in the receive block’s reserve (mem::kRxLoanBytes), and the block goes back to ITS backend when the last segment reference drops, exactly as it did before the value existed. So there is nothing for a value source to hand out or take back: try_alloc refuses and release is never asked. It exists as an IDENTITY — non-null, so a loaned value is a published block to every seam that tells a stack value from a shared one (value_ref_t::keep, the target adopt), and unique, so the last release knows which arm it is on.

Public Functions

inline rx_loan_source_t() noexcept

The singleton’s name, as census and diagnostics print it.

inline virtual void *try_alloc(std::size_t, std::size_t) noexcept override

Refuses: a loaned header is never drawn, it is placed.

inline virtual void release(void*, std::size_t, std::size_t) noexcept override

Never reached: the loaned arm of the last release returns before it.

class inline_value_backend_t : public tr::mem::mem_backend_t

The reclaimer of an inline value’s embedded segment (RFC-0028 §5.1, slice 5).

One process-wide instance, inline_value_backend. It allocates nothing: its only job is the last segment reference’s destroy, which hands the WHOLE block — header, link, segment and bytes — back to the block_source_t the value was drawn from. It is also the identity that marks a segment as embedded: a segment whose backend is this one lives inside a value_t’s block (value_t::inline_owner).

Public Functions

inline inline_value_backend_t() noexcept

The singleton’s name, as census and diagnostics print it.

inline virtual void destroy(view::segment_t *seg) noexcept override

Return the enclosing value block to its source. Defined after value_t.

The last segment reference of an inline value: hand the whole block back.

The header outlives the value on purpose — refs, the count and source are trivially destructible and nothing tore them down — so the reclaimer reads source here.

inline virtual std::size_t alignment() const noexcept override

The inline bytes follow the segment header, so they are aligned to it.

inline inline_value_backend_t &tr::graph::inline_value_backend() noexcept

The one inline_value_backend_t.

Constructed on first use into static storage and NEVER destroyed: a value released during static destruction (a graph with static storage duration) still reaches a live reclaimer.

constexpr std::uint32_t tr::graph::saturate_threshold(std::size_t bytes) noexcept

A share threshold (RFC-0028 §5.3) as the 32-bit word vertex_ext_t stores: anything from UINT32_MAX up saturates to UINT32_MAX, which reads back as SIZE_MAX — copy always.

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

The snapshot width: the build’s tr::graph::kInlineFanout (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

value_ref_t value

The latched LKV; empty ⇒ 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.

retention_t retention = access == app_access_t::WO ? retention_t::NONE : retention_t::LAST

What a write to this field retains (RFC-0028 §5.4). Defaults from the access — a wo field is retention_t::NONE — its write reaches handlers_t::on_app_field_write and stores nothing, since nothing may read it back — and every other field retention_t::LAST. A wo field stores nothing whatever this says. Lands in the padding after access.

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; on a field that retains nothing it is dropped.

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 borrowed vertex_policy_t::app_fields declaration, 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::owned; for a BORROWED install (vertex_policy_t::app_fields) 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 Functions

inline constexpr bool retains_nothing() const noexcept

True iff a write to this field stores nothing: declared retention_t::NONE, or wo (no read surface, so nothing to keep).

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.

retention_t retention = access == app_access_t::WO ? retention_t::NONE : retention_t::LAST

What a write to this field retains — app_field_t::retention, with the same access-derived default. One byte beside access, in the padding the span’s alignment already left, so the slot does not grow; a positional table spells it third ({name, access, retention, descriptor}).

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 16 B hook. Allocated on the first of either vertex_policy_t::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 Functions

inline explicit app_field_group_t(mem::block_source_t &src) noexcept

An empty group whose table draws from src (#1778).

Public Members

app_field_table_t table

The view-slot descriptor table + lazy value store.

app_field_write_hook_t 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. Never fires for a write the field ADMISSION filter refused — that filter lives on the GRAPH (tr::graph::graph_t::admissions_), NOT here: this group is carried by every app-field-bearing vertex, and a filter almost none of them install may not cost all of them a hook.

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. A borrowed declaration leaves owned null 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 declaration packs the slot array and the runtime table’s name+descriptor bytes into owned — ONE allocation for the whole table — and points the slots into it. owned is never mutated or reallocated while slots reference it (a re-install replaces the whole table under the vertex mutex).

Public Functions

inline explicit app_field_table_t(mem::block_source_t &s) noexcept

An empty table whose owned storage draws from s (the vertex’s extension block’s source, #1778).

inline app_field_table_t(app_field_table_t &&o) noexcept

Take o's blocks; o is left an empty table over the same source.

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

Release this table’s blocks, then take o's.

inline bool own(std::size_t bytes) noexcept

Draw owned at bytes.

Return values:

false – The source refused.

inline bool ensure_values() noexcept

Allocate values — one empty byte string per slot — on the first write; a no-op once allocated.

Return values:

false – The source refused; values is still null.

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 the head of owned. Stable across the table’s moves: a move hands over the block, never copies it, and a borrowed span points outside the table entirely.

mem::block_source_t *src

The source every owned block below came from (#1778).

std::byte *owned = nullptr

The owning install’s ONE block: the slot array, then each field’s name and descriptor bytes, which the slots view. Null for a borrowed install. Sized exactly at install and never resized: a re-install builds a whole new table and move-assigns it under the stripe lock. Raw rather than two core arrays so the table is 48 B, under the 56 B its std::vector predecessor took (vertex_app5 gate).

std::size_t owned_bytes = 0

The size owned was drawn at — what its sized release hands back.

mem::bytes_t *values = nullptr

Class-③ per-field values, slots.size() of them, index-aligned with slots — LAZILY allocated, null until the first write to a RETAINING field on this vertex (#389 pattern). A declared-but-never-written table, and one whose only writes went to wo / retention_t::NONE fields, costs zero value RAM (RFC-0028 §5.4). 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.

await_waiter_t *armed = {nullptr}

The armed one-shot waiters of this stripe’s vertices (ADR-0084), guarded by m. Each one is also counted in waiters.

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.