graph — vertices, read/write/await, dispatch (L4)¶
In one paragraph
The graph is the node. A vertex_t is a named, addressable slot holding a value
(a rope_t — a contiguous scalar is the single-link case), a bounded history, or a
user handler. The data surface is read / write / await over the value, plus
assign / propagate when the state transition and the edge transition are wanted
separately; every control surface (subscriptions, QoS) is a field-write to a
:-addressed field. write fans out to subscribers by cloning the value (a refcount
bump, no copy). The last-known-value path takes no per-vertex mutex.
What it does¶
graph_t owns the vertex map (keyed on canonical path bytes). Each vertex
has a role: stored-value (last-writer-wins), stream (the CONSUMER’s bounded ring —
a producer never queues, so the ring lives on the receiving vertex and is bounded in BYTES
by that vertex’s own injected mem::block_source_t via set_ring_source
(RFC-0025 §4.6.1) — whose depth the
owner declares host-side with set_history_depth, and which no peer can read or write —
RFC-0022 §3.C), or handler (on_read / on_write — covering
computed, proxy, sink, live-MMIO patterns). The last-known-value slot is an
atomic<shared_ptr<const rope_t>> swap, so read / write of the value take no
per-vertex mutex; that mutex guards the subscriber list, the history ring and the
await waiter accounting, and a per-vertex condvar makes await block until the next
write.
The slot is not free of serializing instructions. std::atomic<std::shared_ptr<T>> is
not lock-free on libstdc++, so both load and store take its internal pointer-lock bit —
“lock-free by contract, spin-locked in practice” (sp_atomic_slot_t,
core/include/libtracer/lkv_slot.hpp:100-105). The claim the code supports is the mutex
one, not an absence of contention; the cost of that spin and the policy that replaces it
on a host are in design/concurrency.
Subscriptions are field-writes, not a verb
(ADR-0006 — read/write/await API, no connect):
subscribing is writing a SUBSCRIBER TLV into :subscribers[]. On each write the
dispatcher clones the value to every subscriber’s target vertex and in-process callback.
A delivery terminates at its target — store and notify, never a re-dispatch to the
target’s own :subscribers[] — so a dispatch-level cycle cannot form and there is no
depth cap to tune (core/include/libtracer/graph.hpp:87-92;
ADR-0051 — delivery terminates at target, no dispatch limits,
RFC-0007 — delivery terminates at target).
Propagation past a target is exclusively the target’s own logic — a controller
re-emitting on its execution. :schema reads return a POINT descriptor.
Interface¶
enum class role_t { STORED_VALUE, STREAM, HANDLER };
enum class delivery_mode_t { IF_NEWER, UNCONDITIONAL, EXPLICIT };
// There is NO per-vertex settings type. RFC-0022 §3.B deleted `settings_t` outright: four
// of its seven knobs were inert, `durability` became the subscription's (below), and the two
// survivors are construction parameters an OWNER declares — see set_history_depth /
// set_pin_payload_ratio. Nothing is inherited (§3.F).
struct delivery_policy_t { // ONE subscription's delivery policy (RFC-0022 §3.A) — 2 B packed
std::uint16_t bits; // 0-1 reliability | 2-4 priority | 5 durability_request | 6-15 rsvd
};
struct write_ctx_t { // the per-call context a write carries into on_write (#375)
std::string_view subject; // the writer's resolved subject token; EMPTY = the local host
bool is_local_owner() const noexcept; // subject.empty()
}; // BORROWED for the call, like the rope beside it — copy if retained
struct handlers_t { // four seams, not two
std::function<result_t<rope_t>()> on_read;
std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write;
std::function<result_t<view_t>()> on_children;
std::function<void(std::string_view, const view_t&)> on_app_field_write;
};
using subscriber_fn_t = void (*)(void* ctx, const rope_t& value);
class subscription_t { /* opaque: producer vertex + :subscribers[] slot index; graph_t is the
sole friend. Public: default-construct, copy, operator==. */ };
class graph_t {
explicit graph_t(std::pmr::memory_resource* mr = std::pmr::get_default_resource(),
mem::mem_backend_t* value_backend = &mem::heap_backend(),
mem::block_source_t* ctl = &mem::heap_source());
// registration and removal
vertex_handle_t register_vertex(const path_t&, role_t, handlers_t = {});
result_t<vertex_handle_t> try_register_vertex(const path_t&, role_t, handlers_t = {});
result_t<void> retire(vertex_handle_t); // logical absence, subtree-wide
std::uint32_t retire_generation(vertex_handle_t) const noexcept;
void collect(); // free the parked value seams — CALLER-timed
std::size_t parked_seam_count() const; // how many await a collect()
std::optional<vertex_handle_t> find(std::span<const std::byte> key) const;
// the node-scoped vertex index — bound-path addressing (RFC-0024 §6.4)
std::size_t vertex_slot_count() const noexcept;
std::optional<vertex_slot_t> vertex_slot(vertex_handle_t) const noexcept; // mint side
std::optional<vertex_handle_t> deref_vertex_slot(std::uint32_t index,
std::uint32_t generation) const noexcept;
std::optional<vertex_slot_t> vertex_slot_at(std::uint32_t index) const noexcept; // O(1)
bool allows(vertex_handle_t, std::string_view caller, acl_right_t) const; // the §6.2 check
// value plane
result_t<value_ref_t> read (vertex_handle_t, std::string_view caller = {}) const;
result_t<void> write(vertex_handle_t, rope_t, std::string_view caller = {});
result_t<value_ref_t> await(vertex_handle_t, std::chrono::nanoseconds,
std::string_view caller = {});
result_t<void> assign(vertex_handle_t, rope_t, std::string_view caller = {});
result_t<void> propagate(vertex_handle_t);
void set_delivery_mode(vertex_handle_t, delivery_mode_t);
result_t<std::vector<rope_t>> history(vertex_handle_t) const; // stream window (RETAINED)
// RFC-0008 §E drain cursor — what the stream OWES, and how to say it is paid
result_t<std::size_t> drain_unflushed(vertex_handle_t,
std::vector<std::shared_ptr<const rope_t>>& out,
std::uint64_t* gap_before = nullptr);
result_t<void> mark_flushed(vertex_handle_t);
// owner-side storage declarations (RFC-0022 §3.C) — host API only, NO wire surface
void set_history_depth (vertex_handle_t, std::uint32_t keep);
// the RECEIVER's byte bound (RFC-0025 §4.6.1) — admission reservations, not placement
void set_ring_source (vertex_handle_t, mem::block_source_t*,
bool reliable = false);
result_t<std::size_t> ring_reserved_bytes(vertex_handle_t) const;
result_t<std::uint64_t> stream_gaps (vertex_handle_t) const;
void set_pin_payload_ratio (vertex_handle_t, std::uint32_t k);
std::uint32_t pin_payload_ratio (vertex_handle_t) const noexcept;
// composed reads — they build a value, so they return one
result_t<rope_t> read_children_folded(vertex_handle_t) const;
result_t<rope_t> read_children_materialized(vertex_handle_t) const;
result_t<rope_t> read_subtree_folded(vertex_handle_t, ...) const;
// field plane (`:`-addressed)
result_t<rope_t> read (vertex_handle_t, const field_path_t&, ...) const;
result_t<void> write(vertex_handle_t, const field_path_t&, rope_t,
std::string_view caller = {});
result_t<value_ref_t> read (const path_t&) const; // field tail → :schema, …
result_t<void> write(const path_t&, rope_t); // → :subscribers[], :settings.*
result_t<value_ref_t> await(const path_t&, std::chrono::nanoseconds);
// subscriptions
result_t<void> subscribe(const path_t& src, const path_t& target,
delivery_policy_t policy = {});
result_t<subscription_t> subscribe(const path_t& src, subscriber_fn_t fn, void* ctx,
delivery_policy_t policy = {});
template <typename F>
result_t<subscription_t> subscribe(const path_t& src, F& callback); // lvalue only
result_t<void> unsubscribe(const subscription_t&);
result_t<void> unsubscribe(const subscription_t&, subscriber_release_fn_t);
static std::uint64_t deferred_release_drops() noexcept;
};
There is no std::function subscribe overload and no result_t<void> callback form. The
per-edge sink is a {fn, ctx} pair so the per-publish edge snapshot under the fan-out lock
is a trivial copy rather than a std::function clone that heap-allocates once captures
exceed the small-buffer size
(ADR-0047 — build-time closed module sets, compile-time seams).
The templated overload binds callback by address, so it takes an lvalue only — a
temporary lambda does not compile.
```{admonition} ctx lives until the reclamation policy’s grace point — and the library tells you when
:class: important
unsubscribe deactivates the slot (core/include/libtracer/graph.hpp:1697); a
delivery already in flight snapshotted the edge and completes, and the {fn, ctx} pair is
the one leg of that snapshot the library owns no copy of. So “when may I free ctx?” is answered by this build’s reclamation policy
(ADR-0080,
reference/17), not by a rule you have to keep in
your head — and never by asking you to track in-flight state.
Under the default reclaim_local, pass a release hook and be told:
void on_dead(void* ctx) { delete static_cast<my_sink_t*>(ctx); }
(void)g.unsubscribe(sub, &on_dead);
The hook runs exactly once, on your thread, outside every graph lock: inline, before
unsubscribe returns when you called it from outside a delivery (the ordinary case), or
before the enclosing write() returns when you called it from inside one. The
one-argument overload retires the edge identically and simply carries no signal — which is
sufficient whenever you unsubscribe from outside a callback, since that call is already
quiescent on return (core/include/libtracer/graph.hpp:1655 states the bound on ctx).
```{admonition} No strings on the hot path
:class: important
The hot path is **handle-typed** (the spec's rule,
[reference/03](../reference/03-addressing.md) §static path handles).
A `path_t` encodes the canonical PATH bytes **once** — the `path_t(std::string_view)`
constructor for a known-good literal
([ADR-0054 — path_t parse-once constructor](https://github.com/avatarsd-llc/libtracer/blob/main/docs/adr/0054-path-t-parse-once-constructor.md)),
or the fallible `path_t::parse` for a runtime string; `register_vertex` / `find` resolve a
**`vertex_handle_t`** once; then `write(v, value)` and `write(v, fieldpath, value)` reuse
those handles — **no string crafting, no parse, no map lookup per call**. The
string/`path_t` overloads are init-time conveniences.
Injected memory — no allocator baked in
graph_t’s constructor takes three memory seams, all defaulted to the standard heap (a
host that passes nothing gets zero-churn, byte-identical behavior):
a
std::pmr::memory_resource*for the per-write control objects — the LKV control block and therope_twrapper (ADR-0039 — pmr memory model, host-aligned allocation);a
mem::mem_backend_t* value_backendfor the durable value bytes the write path copies into the LKV when a borrowed-delivery transport forces the copy (ADR-0060 — LKV copy store, injected value backend); anda
mem::block_source_t* ctl— the nothrow source for allocations a peer can provoke, which report exhaustion by value instead of throwing (ADR-0065 — failable allocation gets its own seam, block_source). Read it back withcontrol_source().
The parameters are appended in that order, so an existing graph_t{&mr} keeps
compiling and picks up the defaults.
A bounded target points all three — and the transport-receive backend — at one static slab;
pool exhaustion surfaces as BACKPRESSURE, never a silent heap fallback. See
reference/09 §the injection points.
// idiomatic: encode the path once (parse-once ctor), reuse the handle
path_t p("/x:settings.app.setpoint"); // once — no *-deref
auto v = *g.find(p.key()); // once — find → optional<vertex_handle_t>
for (...) g.write(v, p.field(), setpoint_tlv); // hot loop — zero strings
What a read hands back¶
read and await return result_t<value_ref_t>, not result_t<rope_t>
(core/include/libtracer/graph.hpp:1270,1476 by handle, :2073,2079 by path;
value_ref_t at core/include/libtracer/vertex.hpp:241). A value_ref_t is an owning
reference to the value the vertex published: the LKV slot holds it as a
std::shared_ptr<const rope_t>, so handing that reference back costs a refcount clone of
one control block instead of one segment_ptr_t clone per link.
The rule, and the reason the API is not uniform:
A read of a published value returns a reference to it; a read that composes a new value returns the value.
read_children_folded, read_children_materialized and read_subtree_folded compose a
tree no vertex ever published, so there is no object to reference and they still return
result_t<rope_t>. The field-read overload likewise serves a control TLV as a rope_t.
The rejected alternative was a uniform rope_t return: it makes every read of a shared
vertex pay a contended refcount read-modify-write per link, on a cache line every reader
of that vertex shares, so its cost grows with links and with readers.
Spelling a read of a single-link value:
auto got = g.read(v); // result_t<value_ref_t>
if (!got) return got.error();
std::span<const std::byte> b = (*got)->only().bytes(); // (*got) → const rope_t&
operator* yields the rope_t, operator-> reaches its members; only() is the
single-link accessor (zero copy) and materialize() the general one. (*got)->only()
is the correct spelling — one dereference for the result_t, one for the reference.
A held reference pins the value
Holding a value_ref_t keeps the value alive, exactly as the reader’s own copy did. Under
an injected std::pmr::memory_resource that is a real obligation rather than a
formality: the value was allocated from the graph’s resource, so an outstanding reference
pins that allocation and defers its reclamation
(ADR-0069 — LKV slot is a compile-time policy, hazard reclamation).
A reader that parks a value_ref_t in long-lived state holds a bounded pool’s block for
that long.
Assign and propagate¶
write is not irreducible. It is assign — the state transition — followed by
delivery — the edge transition
(RFC-0008 — vertex operations, assign and propagate §D).
Splitting them is what makes “update many fields, notify once” expressible without a
notion of a batch:
Call |
State |
Edges |
|---|---|---|
|
swaps the LKV, appends to the stream ring, bumps the write sequence (waking |
none |
|
none |
delivers |
|
as |
delivers immediately |
assign is WRITE-gated like write and is never gated by delivery_mode. A branch
POINT decomposes and assigns each descendant, notifying nothing. propagate takes no
value — it reads the last-known-value — and always delivers the vertex named, because that
vertex is the explicit target; the mode gates only what an ancestor’s sweep sweeps up.
Its cost is O((pending + unconditional) in subtree).
Both verbs require RETENTION (RFC-0008
Amendment 2). A HANDLER vertex retains nothing — it hands the value to on_write and stores
no LKV — so the pair had nothing to carry between the two calls and the sweep delivered silence.
assign(v, …) and propagate(v) therefore answer SCHEMA_NOT_FOUND when v’s role retains
nothing (the contract-mismatch status, not BACKPRESSURE); use write, which dispatches the
seam and delivers in one step. Only the sweep root is judged. The same amendment makes
await serve its woken value through the same role dispatch read uses, so a handler
vertex answers an await with its on_read-composed value instead of NOT_FOUND.
set_delivery_mode(v, mode) sets that per-vertex policy. It is a wiring-time host API call,
in the same family as set_history_depth, set_pin_payload_ratio and set_app_fields — an
owner declaration with no wire surface.
|
An ancestor’s sweep includes this vertex |
|---|---|
|
only if it was assigned since the last covering sweep — the structural coalescing flush |
|
always, at the sweep’s rate — a sweep-driven keepalive |
|
never; deliverable only by a direct |
The mode is a structural filter, not a value filter: nothing here compares bytes, and a vertex never parses its own bytes. Numeric filtering (a deadband) is an application filter vertex, never a field here. The protocol half of this model — what a peer sees, and how coalescing composes across a link — is reference/02.
Write and fan-out¶
sequenceDiagram
participant P as publisher
participant G as graph
participant V as /sensor/temp
participant S1 as subscriber (callback)
participant S2 as subscriber (target vertex)
P->>G: write(/sensor/temp, rope_t)
G->>V: atomic LKV store (no per-vertex mutex)
G->>V: snapshot subscribers (brief lock)
G-->>S1: fn(ctx, clone) %% refcount bump
G-->>S2: store + notify at the target %% no re-dispatch from there
Note over V: await waiters woken via condvar
G-->>P: OK
The subscriber snapshot is taken under the per-vertex mutex and the sinks are called outside it, so a callback may re-enter the graph. Because a delivery landing on a target does not re-fan from that target, re-entry cannot build a dispatch cycle.
A remote subscriber’s delivery does not go on the wire from here: the fan-out hands
{link, return_route, delivery_compact} and the value to the graph’s injected
remote-delivery sink, which is a tr::net concern. See
fwd-router and transport.
Status codes¶
status_t (core/include/libtracer/status.hpp:25-44) is the error side of every
result_t. When the operation arrived over the wire, the FWD resolver maps it to the
registered tr:: error code the kind=ERROR reply carries (error_code(status_t),
core/src/fwd_reply.cpp:33-77 — a private TU under src/, not part of the
public API).
The table below is a total map, and the compiler keeps it that way: error_code is a
switch with no default: label and no fall-through tail, compiled under -Werror=switch,
so a status_t gained without a row here is a red build rather than a status that goes out
under some other member’s wire code. It reads as a formality only until you notice that the
two enums are deliberately separate registries — status_t is L4 vocabulary, err_t is the
wire’s — which is what makes the mapping hand-written and therefore losable.
|
Wire error |
What produces it |
|---|---|---|
|
|
the path resolves to no live vertex (never registered, or retired), or the vertex holds no last-known-value yet |
|
|
a subject resolver is installed and the target’s effective ACL grants the operation’s right to no matching, non-expired ACE |
|
|
|
|
|
a payload whose type the vertex or field cannot take; also |
|
|
an allocation a peer can provoke could not be served from the injected nothrow control seam, or a per-subscriber queue cap is exceeded |
|
|
an |
|
|
a field read or write on a vertex that exposes no such field — an undeclared app field, |
|
|
|
|
|
a transport-construction failure: a dial refused, a TLS/WebTransport handshake rejected, a listener that could not bind, a CAN interface the kernel would not open |
BACKPRESSURE is the allocation-failure and flow-control answer. It is not a
dispatch-depth signal: no depth cap exists.
TRANSPORT_DOWN is the only member of this table whose point is the disposition, not
the name. Its wire code is TRANSIENT in the registry — retry may succeed — while every
other row here is PERMANENT or (for BACKPRESSURE / TIMEOUT) transient for a reason the
caller can already see. Until #929 the built-in transport factories spent NOT_FOUND on a
link that did not come up, so a refused connect went out as tr::path::not_found and a
peer reading the disposition off the code stopped retrying a link that would have come
back. Nothing before #929 could reach err_t::TRANSPORT_DOWN from this side: the map was
total over a status_t that had no member for it.
Setup-time seams¶
Five installers configure a graph before frames flow. Each is set once at wiring time,
from one thread. That is the doctrine, and since #1049 it is stated by the API rather
than requested in a comment: the three callback seams are spelled configure_* and take
the ADR-0047 {fn, ctx} pair, never a std::function.
The shape is the enforcement. A std::function cannot be handed to a racing reader at
all — assigning one destroys the old target, freeing its captures while a reader may be
inside the call — whereas a bare function pointer is one word, so the pair publishes
through a sink_slot_t exactly as the router’s five sinks do. A fan-out,
an ACL gate or a subscribe that races an install therefore sees the whole new pair, the
whole old one, or no sink for that one operation; it never sees a new fn beside a stale
ctx, and never a freed capture. The read costs what the null check it replaces cost: one
relaxed load when nothing is installed.
The two remaining seams are not callbacks and the slot does not reach them, so they take a
lock instead — which is free, because both are control-plane cold. The child-type catalog
is a std::map whose lookup runs from a peer’s bytes; the identity record is a buffer
whose read is served above the READ gate, to a peer that has authenticated nothing
(RFC-0011 §C), and which install and clear both free. Neither lock touches a read, write or
dispatch path.
The ctx pointer is the caller’s, and must outlive every operation that can still reach
the seam: clearing a sink does not stop a dispatch already in flight.
Seam |
Effect |
Default |
|---|---|---|
|
populates the in-band creation catalog: which |
only the built-in |
|
installs the node-scoped record |
absent — |
|
where the producer fan-out hands each remote subscriber’s delivery |
null — remote subscriber slots are stored but never deliver |
|
maps a caller context to a subject token, enabling ACL evaluation |
none — enforcement is entirely off; every operation is allowed |
|
the inbound |
— (called by the FWD resolver, not a default) |
The two defaults in bold are load-bearing and are the two failure modes a node wired by
hand hits first. A graph with no remote-delivery sink accepts remote subscribes and
records them; nothing ever leaves. A graph with no subject resolver is fully open,
whatever :acl bytes its vertices carry.
set_identity involves no cryptography. The record is a claim: the seam stores and
serves the bytes the owner supplies and verifies nothing. Proving a node holds the key is
authentication and lives elsewhere; a claim is nevertheless what a trust-on-first-use peer
pins and what a topology walk deduplicates by. :identity resolves above the READ
gate, so an unauthenticated peer can fetch it — a narrow, named exemption for that one
field
(RFC-0011 — node identity facet).
Delivery drops¶
A delivery can be lost after the write succeeded. delivery_drops() returns the only
record of it:
struct delivery_drops_t {
std::uint64_t no_target; // no live vertex: an edge's target PATH, or a net-plane route
std::uint64_t denied; // a WRITE was refused by the target's :acl — on any plane
std::uint64_t out_of_memory; // a nothrow delivery clone / edge-view copy could not allocate
std::uint64_t fan_out_truncated; // a wide fan-out's snapshot could not be widened past the
// inline prefix — the capacity degrade, kept apart from OOM
};
The unit is a delivery, not an event: an assign whose pending mark cannot be allocated
sheds every subscriber of the vertex, and a truncated snapshot sheds every edge past the inline
prefix, so each counts once per shed delivery (1 never stands in for N). A HANDLER write
whose notify clone failed used to be the widest case of this; #1505
removed the clone, so that shed is now impossible rather than merely counted.
denied counts a refusal on every plane the value-write path is entered from — an API
write, a FWD{WRITE} terminus, a COMPACT terminus, and a subscription edge’s fan-in gate
— because it is counted at the graph’s own WRITE gate rather than once per deliverer (#1068).
It is therefore refusals, not refusals nobody was told about: an API caller both receives
PERMISSION_DENIED and counts here. A number that depended on which door a refusal came
through could not be summed. assign, a control-plane field write and a denied READ are each
a different right or a different path, and are deliberately not folded in.
A deliverer outside the graph — the net plane resolving a label to a vertex and writing it
— counts its own abandoned deliveries through count_external_drop, the one public door to
these counters. It names only NO_TARGET and OUT_OF_MEMORY: a denial is counted at the gate
that produces it, so offering it there would count one refusal twice.
Counted, never enforced: nothing in the library reads them, so a deployment chooses whether to alarm. They are relaxed monotonic and incremented only on a drop, so the delivering path pays nothing when nothing is dropped. The loads are individually relaxed rather than one atomic snapshot — making them coherent would put a lock on the delivery path to serve a diagnostic, and the useful reading of a monotonic counter is “is this growing”, not an instant.
A subscriber whose target was retired, or whose caller lost the WRITE right, silently stops receiving. There is no other instrument for that.
Declaring owner fields¶
Application properties live under :settings.app. and are declared by the owner, never
invented by a peer. Declaration is a local host call with no wire operation behind it: the
field catalog is device state
(RFC-0010 — owner app fields and schema §A.1).
Every undeclared name answers SCHEMA_NOT_FOUND.
enum class app_access_t { RO, RW, WO }; // constrains REMOTE callers only
struct app_field_t { // owning install
std::string name; // key below settings.app. ("kp", "wifi.ssid")
app_access_t access;
std::vector<std::byte> descriptor; // §B.1 record served verbatim inside :schema
std::vector<std::byte> value; // optional initial value
};
void set_app_fields (vertex_handle_t, std::vector<app_field_t>); // owning
void set_app_fields_static(vertex_handle_t, borrowed_fields_t); // borrowed, zero-copy
|
|
|
|---|---|---|
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 |
|---|---|
|
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 |
|
freeing |
Two of the three policies speak for ONE thread’s dispatch domain |
|
A re-entrant unsubscribe needs a parking slot |
|
|
keeping a |
|
calling it on a multi-link rope is not the general path; |
A retired handle stays dereferenceable |
|
|
the seam is read lock-free, so |
No subject resolver means no enforcement |
writing |
No remote-delivery sink means no remote delivery |
remote subscribes are accepted and stored; the |
Consequences¶
Two irreducible operations, not one.
assignandpropagatecompose intowrite; 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
awaitaccounting. Race-freedom under TSan is evidence about data races, not about blocking — the slot’s serializing instructions are real and measured in design/concurrency.Zero-copy fan-out. N subscribers get N refcount clones of one
rope_t, not N copies.No dispatch limits. Delivery terminating at the target removes the cycle, so no depth counter, no hop budget, and no synthetic constant to tune per deployment.
The value is the bytes. A vertex stores a
rope_t, so what it holds is exactly what goes on the wire.
API reference¶
-
class graph_t¶
The L4 in-process graph runtime: the Composite vertex tree plus the whole data API (register / read / write / await / subscribe, ADR-0006).
Vertices form a Composite tree (ADR-0057): each node stores its own NAME segment and its children; a canonical PATH-TLV payload key (docs/reference/02 §dispatch) resolves by an O(segments) child walk at wiring frequency. The hot path resolves a
vertex_t*once — at registration or via one guarded find — then read/write/await on that handle are lock-free in the vertex’s last-known-value slot. Non-copyable; a graph is a fixed runtime root.Public Types
-
enum class external_drop_t : std::uint8_t¶
Why a deliverer OUTSIDE the graph abandoned a delivery before it could write.
Narrow on purpose (#1068). It names only the two ways a net-plane delivery dies without ever reaching write — the route resolves to no vertex, or the payload view cannot be allocated. There is deliberately no
DENIED: a refusal happens AT the graph’s own WRITE gate, which counts it there, so offering it here would let one refusal be counted twice by a caller that also sawPERMISSION_DENIED.Values:
-
enumerator NO_TARGET¶
-
enumerator OUT_OF_MEMORY¶
-
enumerator NO_TARGET¶
-
using child_factory_t = std::function<result_t<vertex_handle_t>(graph_t&, std::vector<std::byte> child_key, const wire::tlv_t *config)>¶
A child-vertex factory: the device-catalog entry ADR-0017 makes concrete.
Given the composed child key (parent key + the SPEC’s
nameNAME) and the optional SPECconfigSETTINGS, 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 atypeinstantiates).
Public Functions
-
explicit graph_t(std::pmr::memory_resource *mr = std::pmr::get_default_resource(), mem::mem_backend_t *value_backend = &mem::heap_backend(), mem::block_source_t *ctl = &mem::heap_source(), mem::block_source_t *ring = &mem::heap_source())¶
Construct an empty graph (registers the built-in
stored_valuechild type).Construct a graph drawing its per-write control-block allocations from
mr(ADR-0039 §1, #361 §5) and its write-path value byte-buffers fromvalue_backend(ADR-0060).mrallocates the LKV control block +rope_twrapper object;value_backendis the L0 byte-buffer seam the write-path copy-store draws its owned value view::segment_t from — the single flatten of a branch or field write (graph.cppsites 825, 1017). A bounded node points BOTH at one static slab (“one slab, whole stack”); a host passes nothing and gets the standard heap for each (zero churn, behaviour byte-identical).The seam’s scope is PAYLOAD bytes, which includes READ-path framing and not only the write-path copy-store (#831): BOTH folded READs frame their exactly-sized POINT headers from it — one per subtree node in the composed-root fold, and one per registered child plus the outer listing header in the
":children"fold the wire field READ routes to. These are payload bytes whose length field wraps the stored TLV and the name records below it, as distinct from the route-byte-sized reply-egress seam of ADR-0074. Both counts are peer-influenced, so an injector sizing a bounded slab must budget for them; the size classes are the host’s composition problem (ADR-0060 §3 keeps the graph size-agnostic).An injected
value_backendMUST be thread-safe (ADR-0060 §2): a value view::segment_t self-routes its reclaim on whatever thread drops the last ref — typically a reader/subscriber, concurrent with a writer’salloc— so sharding it per lock-stripe removes no race. The defaultheap_backend()already is thread-safe; apool_tmust be composed with the target’s arch-selected synchronisation. On exhaustionvalue_backendreturnsnullptr(the BACKPRESSURE signal), and the write rejects rather than silently falling back to the heap (§3).mrandvalue_backendmust both outlive the graph and every value handle obtained from it.- Parameters:
ctl – The #551 nothrow seam every FAILABLE allocation draws from — the ones a PEER can provoke (“failable”, not “control-plane”: CONTEXT.md binds that phrase to the
:field-write plane): vertex registration first, then theroute_handlelabel tables,tlv_arenanodes,fwd_routeriov andcan_reassemblymaps as each migrates. On exhaustion it returnsnullptrand the operation answers BACKPRESSURE, so a peer’s CREATE frame can no longer reboot a-fno-exceptionsnode. Deliberately a DIFFERENT C++ type frommrso the two contracts (must-not-be-null vs may-be-null) cannot be transposed by a one-token edit, and so retiringmrlater is a compile error rather than a silent rebind. Appended, not prepended, so every existinggraph_t{&mr}call site compiles unchanged. Must outlive the graph, like the other two.ring – The GRAPH-LEVEL DEFAULT receiver-ring source (RFC-0025 §4.6.1 clause 3): where a STREAM vertex’s ring admissions are charged when that vertex has declared no source of its own (set_ring_source). It is a DEFAULT so that every receiver has somewhere to charge — not a shared pool: composition stays per-injection-point, and per-vertex isolation is a tested property. A different seam from
ctlon purpose — exhausting a node’s control budget and exhausting one plane’s queue budget are different failures with different blast radii, and the flood test asserts exactly that separation. Must outlive the graph.
-
inline mem::block_source_t &control_source() const noexcept¶
The injected #551 nothrow failable-block seam (tr::mem::block_source_t).
Exposed so a host can name it in a memory census and so the wiring is observable without reaching into the graph’s state. Callers inside the library draw from
ctl_directly.
-
inline mem::block_source_t &default_ring_source() const noexcept¶
The graph-level DEFAULT receiver-ring source (RFC-0025 §4.6.1 clause 3).
What a STREAM vertex charges its ring admissions against until it declares its own through set_ring_source. Exposed for the same reason control_source is: so a host can name it in a memory census and so the wiring is observable.
-
vertex_handle_t register_vertex(const path_t &path, role_t role, handlers_t handlers = {})¶
Register a vertex at a known-good
pathLITERAL, parsing nothing further (any:fieldtail is ignored) — INFALLIBLE (ADR-0056).The init-time registration form: a
PATH_IN_USEcollision on a compile-site literal is a source bug, not a runtime condition, so this hard-aborts (likepath_t(std::string_view), ADR-0054) rather than yielding aresult_tthe caller would only*-deref unchecked. Returns the pinned vertex_handle_t directly — no*. For a genuine runtime path whose collision is a real outcome, use try_register_vertex.
-
result_t<vertex_handle_t> try_register_vertex(const path_t &path, role_t role, handlers_t handlers = {})¶
Register a vertex at
path— FALLIBLE (the runtime-path form of register_vertex).- Returns:
The pinned vertex_handle_t, or
PATH_IN_USEif the path is already registered.
-
result_t<vertex_handle_t> register_vertex_key(std::vector<std::byte> key, role_t role, handlers_t handlers = {})¶
Register a vertex by its canonical PATH-payload
keydirectly (the in-band:children[]path) — FALLIBLE.The key is a composed parent-key +
NAME(child), not parsed from a string. This is the genuine runtime path (a:children[]write can race a duplicate name), so it stays fallible.- Returns:
The pinned vertex_handle_t, or
PATH_IN_USEif 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 tofind/read/:children[], readingtr::path::not_foundexactly like a never-built path (§C). The allocation is NOT freed and the handle stays dereferenceable forever (ADR-0057 insert-only) — the vertex is emptied, not erased. Retirement re-virginizes each vertex (§B.6): it clears the previous owner’s:acl, value seam, stored value, history, app-field table, subscribers, owner-side storage declarations, and delivery mode, so a later write-creates revive of the same address inherits nothing of the retired owner — in particular the revived path inherits its live ancestor’s ACL policy, never the retired one’s (the §Discussion-7 ruling: an ACL does not survive churn).write_seq_survives (monotonic per address).Delivers nothing and wakes no
await(§B.5). Idempotent (§B.4): retiring an already-retired or unregistered vertex succeeds and does nothing. The root cannot be retired. There is no wire operation that reaches here — a peer goes through the device’s own logic (§A.1 / §A.1.1), which is what calls this.
-
std::uint32_t retire_generation(vertex_handle_t vh) const noexcept¶
vh'sretirement generation — the stamp a cached resolution carries (ADR-0062).A
vertex_handle_tnever 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_aboverather 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
vhreach 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 thelisteners_abovegatedeliver_vertexholds overbubble_up— so a producer that skips adeliver_vertexonfalseskips exactly what that call would have found no receiver for. (A decomposing BRANCH write is not onedeliver_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 ondelivery_modefirst and so asks a third question this predicate deliberately does not.)Note
Swapping the
seq_cstown 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.
readpollers 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 onfalseis fine; one that also skips the VALUE STORE starves every awaiter (nowrite_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_cstvertex_t::own_subs_ordered and the relaxedlisteners_above— making it exactly as ordered asdeliver_vertex’s own two gates and no more. Theseq_csthalf’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_aboveis a relaxed load, so afalsehere 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 — theseq_cstcandidate doubled the idle write’s fence count on rv32 and bought nothing): per the #555 standard, the outcome a stale-falseskip 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_tever 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_verticesallocations, charged against the vertex_slot_count census (#1314).The census already counts every
vertex_tthis 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-createmkdir -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_titself draws from the injectedctlseam, 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_peersaccept 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.
idis the session’s node-scoped identity string — the router composes it from the mount’s qualified name and the peer’s slot name, so the SAME slot always asks for the SAME anchor.An anchor is not part of the addressable tree, deliberately. It hangs off a private structural root that
root_cannot reach, so:find,read, every path descent and every:children[]listing are byte-for-byte unchanged — an anchor is invisible to all of them. That is what keepsbus_link_t::enumerate_peersthe 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
dstreaches 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
idis allocated ONCE and re-filled afterwards, exactly as a retired addressable vertex is revived by a second registration at its path — so a recycledp<slot>returns the SAMEvertex_tin the SAME slot with only the saturating retire generation bumped (RFC-0024 §4.4 rule 3). Anchor count is therefore bounded by the listener’smax_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 –
idalready 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, orstd::nulloptwhen 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 singlemap_mutex_hold, which retirement takes uniquely, so the pair is always a consistent snapshot.Warning
This is a control-plane call and is priced as one: it finds
vhby scanning the slot index, because the reverse direction is deliberately not memoized. A per-vertex index field costs 4 bytes on rv32, wheresizeof(vertex_t)sits at its ceiling with zero headroom (config_t::kMaxVertexBytes32), and a pointer→index side map costs strictly more than the 4 B/vertex RFC-0024 §6.4 priced. A mint happens once per binding, on a reply already being assembled; the hot path — deref_vertex_slot — pays a bounds check and one compare and never comes here.- Return values:
std::nullopt –
vh'sgeneration 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,vhis 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
indexagainst vertex_slot_count, refuses a SATURATEDgenerationoutright, 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
kGenerationSaturatedelement 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_allowsat 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 –
indexis 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, whichderef_vertex_slotrefuses 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
vforcallerandright— 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
:aclwrite 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_identityallocates thevalue_handlers_tiff at least one ofon_read,on_write,on_childrenwas installed at registration;role_tis never consulted. So arole_t::STORED_VALUEvertex registered with anon_childrenparks one seam on retirement, and arole_t::HANDLERregistered 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_connectionretires the/net/<module>/<name>identity vertex, which is registeredrole_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 wiredpeer_named = true, get anon_childrenthat 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 onevalue_handlers_t(~96 B ofstd::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,
finda 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 beforemap_mutex_androot_, 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_childreninstalled at registration, whatever itsrole_t— and drops to zero on collect. A retired vertex with no value seam parks nothing, including arole_t::HANDLERone 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 wiredpeer_named = true) and zero for every point-to-point connection — so on a default deployment this legitimately never leaves 0.
-
std::size_t evict_link_edges(std::string_view link_name)¶
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 sharedmap_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_namematches 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 (thedelivery_compactopt-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.
-
std::size_t link_edge_candidates(std::string_view link_name) const¶
How many vertices a evict_link_edges for
link_namewould EXAMINE — the departure’s cost, observable (#1071).A diagnostic, and the instrument the #1071 acceptance test asserts on: before the per-link index this number was “every vertex in the graph holding any subscriber
edge”, so one peer’s hangup was priced by every OTHER peer’s subscriptions. It is now the count of vertices that peer itself ever subscribed on.
Reports the INDEX’s size, not a live edge count, and the two differ by design: the index is a superset that keeps a vertex after an individual unsubscribe (see
link_index_), so this can exceed the number of edges an eviction would actually reclaim. It is an upper bound on work, which is exactly what the scaling property is about — never read it as “how many edges this link has”.- Parameters:
link_name – This node’s NAME for the link; empty ⇒ 0 (a local edge is not reachable by link teardown).
- Returns:
The number of candidate vertices, i.e. the departure’s bounded cost.
-
link_id_t intern_link(std::string_view link_name)¶
Mint-or-find
link_name'sinterned 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.
-
link_id_t intern_link_hinted(std::string_view link_name, std::uint32_t &hint)¶
intern_link with a caller-held SLOT HINT — the O(1) door for a caller that has somewhere stable to keep one word (#1437).
intern_link’s mint-or-find goes through the name door, and that door is a LINEAR SCAN of the live slots (the private
link_index_’s “DENSE SLOT VECTOR” block spells out why — deliberately NOT an@ref, because that anchor lives on a private nested type the rendered API reference does not emit, and a link to it is a-n -Wsphinx 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.
-
void release_link(link_id_t token)¶
Retire
token'sslot so it can be reused — the LINK-DOWN half of intern_link.Bumps the slot’s stamp, so every copy of
tokenstill 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_edgesalready 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.
-
std::size_t link_index_name_lookups() const¶
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, afield_writecallerfallback — and one persubscribe_wirereached with no transport plane behind it, which is every host-side caller and every test that binds an edge directly. The purely LOCALsubscribe()doors never reach here at all: they admit noremote, 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::invalidrefusal (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 alongroute_wireoverlink_name:both keys are required, and the route compare is BYTE-equal on the stored PATH TLV (seevertex_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_REFa 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
vhper the block above: the anchor’s (mount, peer), or nullopt for every ordinary vertex. Lock-free — the key record is immutable.
-
template<typename Fn>
inline void for_each_vertex(Fn &&fn) const¶ Visit every REGISTERED vertex once, in ascending canonical-key BYTE order — the graph’s enumeration surface (a census, a directory listing, a paginated
/system/…projection).fnis invoked asfn(wire::key_view_t key, vertex_handle_t vh).keyis the vertex’s full canonical key (concatenated NAME records, thePATHpayload) 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 deepregister_vertexcreates) are SKIPPED: they are addressing scaffolding, not vertices an owner declared, andfinddoes 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 needskey, and rendering the keys is alreadyO(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-keyedstd::sets), and it earns its keep the same way: the length-prefixed NAME framing makes a parent’s key a byte-prefix of every descendant’s, so a parent always precedes its subtree and that subtree is a CONTIGUOUS run. The result therefore reads as a stable pre-order tree listing.Concurrency: the {key, vertex} snapshot is taken under ONE shared
map_mutex_hold andfnruns OUTSIDE it — the same two-phase discipline evict_link_edges and the fan-out sweep use. SofnMAY 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 (/zonebefore/sensorbefore/actuator). A consumer that wants alphabetical DISPLAY order sorts what it collected; what this promises is stability and subtree contiguity.Warning
CONTROL-PLANE ONLY and priced as such: it allocates one owned key per registered vertex plus the snapshot vector, then sorts. Do not put this on a delivery or write path.
-
void register_child_type(std::string type, child_factory_t factory)¶
Populate the device creation catalog (ADR-0017): map a SPEC
typeselector to a child_factory_t.A
:children[]SPEC write whosetypeis unregistered returnsSCHEMA_NOT_FOUND(the ENOTTY of an unsupported creation). The built-instored_valuetype is registered by the constructor.CONFIGURATION, like the three
configure_*sinks: populate the catalog at setup, before frames flow. Unlike them the catalog is astd::map, so #1049’s{fn, ctx}publication does not reach it — a concurrent insert rebalances a tree the in-band creation path may be walking. Registration and lookup therefore take a lock, which costs nothing: both are control-plane cold (one map lookup per created vertex) and neither is on a read, write or dispatch path. Violating the setup-only contract is consequently slow rather than corrupting.
-
result_t<value_ref_t> read(vertex_handle_t v, std::string_view caller = {}) const¶
Read a resolved vertex’s stored value (the hot path — lock-free in the LKV slot).
Returns the last-known-value as a rope (ADR-0053 §6): a scalar is the single-link case; a consumer needing contiguous bytes calls
rope_t::only()(single-link, zero copy) orrope_t::materialize(). The trailingcalleris 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_readseam keeps precedence over the composed read.
-
result_t<void> write(vertex_handle_t v, rope_t value, std::string_view caller = {})¶
Write a resolved vertex’s value:
assignthen deliver (RFC-0008 §D).Takes a rope; an existing
view_tcaller compiles unchanged via the implicitview_t→rope_t.calleris the ACL caller context (see read).
-
result_t<void> write(vertex_handle_t v, const field_path_t &field, rope_t value, std::string_view caller = {})¶
Field-write by handle: resolve the vertex_handle_t and field_path_t once, then reuse them on the hot path — no string parse, no map lookup per call.
An empty
fieldis an ordinary value write. Passpath.field()for the field selector. A field write targets a contiguous control TLV, so a multi-link value is materialized first.
-
result_t<void> assign(vertex_handle_t v, rope_t value, std::string_view caller = {})¶
Assign a vertex’s value — the STATE transition only, sends NOTHING (RFC-0008).
One of the two irreducible operations
writecomposes: 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'srole RETAINS NOTHING (aHANDLER), 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 theon_writeseam 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 —
vis 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_modegating, same subtree, same drained pending marks. Only the FRAMING differs, andemission_mode_t::PER_VERTEXis exactly the one-arg overload, so the shipped default moves under nobody.Under
emission_mode_t::FOLDthe sweep emits one branch-write frame for the swept subtree instead of RFC-0008 §D’s oneFWD{WRITE}per selected vertex: the RFC-0016POINTtree of the selection, node shape byte-for-byte RFC-0005 §B’s (leadingNAME, optionalVALUE, recursivePOINTsub-branches), the root carrying its own leadingNAMEper §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-lessVALUETLV. 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 payloadTIMEchildren. A selected STREAM vertex refuses here too: its since-flush LIST cannot ride a §B node, which admits at most oneVALUE.BACKPRESSURE— the fold could not be framed within the injected memory seams.SCHEMA_NOT_FOUND— the root retains nothing; the VERB’s refusal, shared with the one-arg overload, so both modes answer alike for the same root.
- Parameters:
v – The sweep root; always delivered, never gated by its own
delivery_mode.mode – The emission mode.
-
void set_delivery_mode(vertex_handle_t v, delivery_mode_t mode)¶
Set v’s per-vertex propagation policy (RFC-0008 §C).
A wiring-time call (the “configure before frames flow” contract), like settings; maintains the sweep’s UNCONDITIONAL membership. Default (unset) is IF_NEWER.
-
void set_history_depth(vertex_handle_t v, std::uint32_t keep)¶
Declare how many entries
v'sSTREAM ring retains (RFC-0022 §3.C).The ring depth is not protocol QoS: it encodes what the APPLICATION wants kept, and only the application can supply it. So it is an owner-side wiring call in the shape of set_delivery_mode and set_app_fields — a declaration the owner makes host-side after registration — and it has no wire surface at all: no peer can read it and none can write it. Callable at any time; the next append trims to the new depth.
keepof 0 behaves as 1 (the ring always keeps the last value).Meaningful on the STREAM role, which is the only role that appends a ring; setting it on another role stores the number and changes nothing. Costs a STREAM vertex zero additional bytes — a STREAM identity already allocates the extension block.
-
void set_ring_source(vertex_handle_t v, mem::block_source_t *src, bool reliable = false)¶
Bind the RECEIVING vertex
v'sown ring source and §4.4 pressure arm (RFC-0025 §4.6.1 clause 3) — owner-side wiring, no wire surface.A producer never queues; the queue belongs to whoever consumes it, and it is bounded in BYTES by that party’s own injected
tr::mem::block_source_t. This is the seam that injects it, sited beside set_history_depth because the two compose: the depth is the owner’s retention INTENT (entries), the source is the BOUND (bytes), and a shortfall surfaces through §4.4’s pressure contract rather than as a silent shrink of the depth.Charging is reservation ADMISSION, not placement. Each admitted entry reserves its retained width from
srcand holds it until the entry retires. The payload bytes physically stay with the allocators that already hold them — theshared_ptrzero-copy handoff is preserved and an append is still a refcount bump. Physical placement migration is the later #873 family, not this seam. A reader who assumes the ring’s bytes move intosrcwill be wrong.Per-injection-point, never a shared pool. ADR-0079’s amendment measured a folded source collapsing to 0.01x of its own single-thread rate at T=24; composition is a knob varied per target, and one receiver running its source dry must not affect another.
REBINDING DRAINS: every queued entry’s reservation is released to the source that served it and the ring is emptied, so this is a wiring-time call like its neighbours.
- Parameters:
v – The receiving STREAM vertex. Meaningful only on that role; on another it stores the wiring and changes nothing, exactly as set_history_depth does.
src – The source to charge against;
nullptrrestores default_ring_source.reliable – The §4.4 arm.
false(default) is BEST-EFFORT: a refused admission sheds the oldest entry whole, accounts the loss, and raisestr::flow::address_shift_gapin order at the shed point.trueis RELIABLE: the admission is refused, nothing is shed, the ring never grows past its byte bound, and the LOCAL producer’s write answersstatus_t::BACKPRESSURE. There is no wire carrier for backpressure in v1 — the per-edge credit window is parked as the v2 escalation (§4.6.1 clause 7) — so a remote producer sees the local receiver’s drop tally, not a stall.
-
result_t<std::size_t> ring_reserved_bytes(vertex_handle_t v) const¶
Bytes
v'sreceiver ring currently holds RESERVED against its source — the byte bound’s observable.SCHEMA_NOT_FOUNDon a non-STREAM role, matching history.
-
result_t<std::uint64_t> stream_gaps(vertex_handle_t v) const¶
Shed points on
v'sreceiver ring since registration — the cumulativetr::flow::address_shift_gapcensus (RFC-0025 §4.4: a shed with no accounting is non-conforming).SCHEMA_NOT_FOUNDon a non-STREAM role.
-
void set_pin_payload_ratio(vertex_handle_t v, std::uint32_t k)¶
Declare
v'sRFC-0022 §3.D pin amplification ratioK(ADR-0042 §3); tr::graph::kPinNever (0, the default) disables pinning on this vertex.kis a RATIO, not a byte count. A view-delivered WRITE is stored as a refcounted SUBVIEW of the inbound frame — no allocation, no copy — iffpayload_bytes * k >= segment_bytesand the payload is trailer-less; otherwise it takes the one-copy trailer-sliced store. Pinning holds the WHOLE inbound segment for the value’s lifetime, so it buys latency and pays in RAM bounded at(k-1)xthe payload; that trade is a deployment call, which is why this is an owner-side declaration and not, since RFC-0022 §3.B, a remotely writable knob. Nothing is inherited (§3.F).What “for the value’s lifetime” costs on a POOLED RX backend: the pin is a borrow of a pool slot — receive capacity — held until the value is displaced, not merely for the delivery window. The library keeps that deferred release safe (atomic segment refcounts); the APPLICATION owns the occupancy budget, since only it knows the pool geometry and the retention pattern. Size against
live pinned values x segment_bytes:kbounds the waste per value and never the number of values. Declaring a non-sentinelkon a long-held vertex — a config vertex, a rarely-updated setpoint — is exactly the shape that starves a small pool. See tr::graph::config_t::kPinPayloadRatio for the target-class guidance (NARROW sets the sentinel; WIDE/MID may borrow freely).Note
This is a per-vertex OVERRIDE of
config_t::kPinPayloadRatio, which Amendment 2 fixes at the sentinel on both targets. It exists so §6-style measurement arms rotate inside one process — measuring them as separate binaries is what produced a 2.8x swing on identical code. Setting it IS the opt-in for that vertex; the override’s existence changes nothing shipped, since both defaults are the sentinel.
-
result_t<value_ref_t> await(vertex_handle_t v, std::chrono::nanoseconds timeout, std::string_view caller = {})¶
Block until the vertex’s value changes or
timeoutelapses; return the value.The READINESS FORM OF A DATA READ (RFC-0008 §A:
awaitobservesassigns 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. AHANDLERvertex therefore answers from itson_readseam (RFC-0008 Amendment 2, correcting a wake that used to answerNOT_FOUNDafter the awaited write landed); a handler exposing noon_readstill answersNOT_FOUND, which is the read contract’s own degradation, not await’s.The BRANCH fork read takes is deliberately not mirrored: await watches this vertex’s own write sequence, so a branch vertex hands back its own last-known-value, not the composed subtree fold. The READ gate is checked BEFORE the wait, so a denied caller cannot camp on the condition variable.
- Returns:
The value, or a
status_t(TIMEOUT,PERMISSION_DENIED,NOT_FOUND).
-
result_t<rope_t> read(vertex_handle_t v, const field_path_t &field, std::string_view caller = {}) const¶
Field-read by handle (the read dual of the field-write overload).
An empty
fieldis an ordinary value read (the stored rope); otherwise serve:schema,:acl, or a single:subscribers[N]slot (the slot’s stored SUBSCRIBER view, zero-copy) as a single-link rope. For the whole-array:subscribers[]read use read_subscribers. Used by the FWD resolver.
-
result_t<std::vector<view_t>> read_subscribers(vertex_handle_t v, std::string_view caller = {}) const¶
Read the
:subscribers[]array — the populated slot SUBSCRIBER views in slot order.Each is a zero-copy refcount clone of the stored source view. The FWD resolver ropes these under a fresh PL=1 wrapper into the REPLY (RFC-0004 §D, no byte copy).
-
result_t<std::vector<rope_t>> history(vertex_handle_t v) const¶
Stream history, newest last (Stream role only) — each entry the stored rope value.
Drain
v'sSTREAM 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” —
historyshows 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_gapsignal 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 –
vis 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'sSTREAM 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_branchalready uses internally after it fans a decomposed slice out eagerly. Takes no cursor argument: the cursor is the per-vertex “appended since flush” count and the only thing a flush can say about it is “nothing is owed”.Ungated beyond the role check, unlike drain_unflushed — it discloses no bytes and has no wire surface — the same owner-side shape propagate and set_history_depth carry.
- Return values:
status_t::SCHEMA_NOT_FOUND –
vis not a STREAM.
-
result_t<rope_t> read_children_folded(vertex_handle_t v) const¶
FOLDED projection of the
:childrenlisting (L4 fold, Slice 0) — the SAMEPOINT{ POINT{NAME}… }that the materializedread_childrenserializes, but produced as a scatter-gather rope (an outer POINT header link plus one link per registered child) instead of one flat buffer.A read-only projection over the materialized tree — the tree stays the source of truth; this walks it and gathers rather than copying the whole listing into a single allocation.
read_children_folded(v).flatten()is byte-identical to the materializedread_childrenserialize, whichfolded_children_testgates over many graph shapes. The rope is valid while the graph (and its insert-only, pointer-stable vertices) outlive it. The synthesized-listing case (ADR-0044) has nothing to gather and crosses as a single-link rope. Each member’s NAME bytes are borrowed IN PLACE (zero copy, view::borrow_const) over the pinned child vertex — only the tiny POINT headers are emitted — so the listing is never copied whole.
-
result_t<rope_t> read_children_materialized(vertex_handle_t v) const¶
MATERIALIZED
:childrenlisting — the flat single-link serialize of the samePOINT{ POINT{NAME}… }the fold gathers.The production field read serves the FOLDED rope; this flat form exists as the independent oracle
folded_children_testdiffs the fold against (byte identity on flatten() over many graph shapes) — without it the differential would be tautological.
-
result_t<rope_t> read_subtree_folded(vertex_handle_t v, std::string_view caller = {}) const¶
COMPOSED BRANCH READ (RFC-0005 §C follow-on): the POINT tree of
v'sregistered subtree, folded as a scatter-gather rope of views over the live last-known-value ropes (zero flatten, zero byte copies).composed(target) = POINT{ [stored TLV of target]?, child_node* }andchild_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 HANDLERon_readseams are not invoked). Unregistered placeholders are skipped exactly asread_childrenskips them; synthesizedon_childrentransport listings are not graph children and are absent. A vertex thecallermay 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.llauto-widened at the same 0xFFFF boundary aswire::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 isBACKPRESSURE.It does NOT rely on
kMaxSegments, and this comment used to claim it did (“graph depth
is `kMaxSegments`-bounded structurally”). That claim is false:
kMaxSegmentsis enforced only inpath_t::parse(core/src/path.cpp:110), the LOCAL string→bytes builder.ensure_vertextakes 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-dstarm 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 surviveskMaxSegmentsbeing 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
srcto atargetvertex — a write to src re-dispatches the cloned value to target (spec-faithful).NOT_FOUNDif 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 aSUBSCRIBER{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
SETTINGSchild, 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
srcto 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 — nostd::functionclone. 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;
ctxis passed back as its first argument.ctx – Caller-owned context, passed back to
fnon 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 beforeunsubscribe()returns when called from outside a delivery, and before the enclosingwrite()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
srcor a denied SUBSCRIBE gate.
-
template<typename F>
inline result_t<subscription_t> subscribe(const path_t &src, F &callback, delivery_policy_t policy = {})¶ Subscribe
srcto a caller-owned callable (sugar over the{fn, ctx}form).Zero-erasure sugar mirroring
transport_t::set_receiver:callbackis bound by address (lvalues only — a temporary would dangle). Its lifetime bound is the{fn, ctx}form’s, since it IS thectx: 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
subreturned 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 producersubnames), 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 returnsNOT_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 itsctxon 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
suband be TOLD when itsctxis 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.
releaseis invoked exactly once with the subscription’scallback_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
inline, before this call returns
forbidden
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
releaseand 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: aNOT_FOUNDreturn (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_tthe 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;
nullptrdegrades 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_FOUNDwhensubnames no active edge — and thenreleaseis not called.
-
void set_app_fields(vertex_handle_t v, std::vector<app_field_t> table)¶
Install (or replace)
v'sfield descriptor table — the OWNER declaring its application property fields under:settings.app.(RFC-0010 §A).A local, owner-facing host API, the mirror of register_vertex (the RFC-0009 §A.1 doctrine: the field catalog is device state, so there is no wire operation that declares a field) — remote peers write DECLARED fields, per their declared
app_access_tand under the vertex WRITE right, never invent them; every undeclared name keepsSCHEMA_NOT_FOUND(theENOTTYdefault). Entries may carry an initial value and the §B.1 descriptor bytesread :schemaserves verbatim (after the runtime-projectedaccessmember). Replacing the table is atomic with respect to concurrent field operations onv; an empty table uninstalls (back to the closed pre-RFC surface). Callable at any time — declaration is not one-shot. App-field writes never wakeawaitand never propagate (§C): a change consumers should notice is followed by the owner’s ordinary announce write.
-
result_t<void> set_identity(std::uint8_t kind, std::span<const std::byte> key)¶
Install this NODE’s identity — the key
read <vertex>:identityserves (#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
:identitywith 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/band/c/a/bcan 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_identityandread_identitynevertheless 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 –
kindis outside the registry (0x00is reserved-invalid), orkey’s length contradictskind.
-
void clear_identity()¶
Drop this node’s identity —
:identityreverts toSCHEMA_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
ENOTTYof an unsupported field, byte-for-byte the pre-RFC behaviour.
-
void set_app_fields_static(vertex_handle_t v, borrowed_fields_t table)¶
Install (or replace)
v'sfield descriptor table from BORROWED, static-storage declarations (ADR-0058) — the same owner-facing semantics as set_app_fields, but thename/descriptorbytes are VIEWED, never copied.For an MCU owner whose field table is
constexprin flash, this costs zero declaration RAM: the runtime viewstableitself, so the caller MUST keep and the bytes it points at alive for the vertex’s lifetime (pass astatic/constexprarray in flash /.rodata, never a stack array or a soon-freed heap block). Note this is the ARRAY as well as its bytes — an earlier revision copiedtable'sentries into an owned vector, so only the bytes had to outlive the vertex, and the “zero declaration RAM” above was untrue by ~200 B per vertex on host (ADR-0058 erratum 1; measured by thevertex_app5_staticgate row). Declaration only — no initial value; write values later through the field-write surface. Emptytableuninstalls, exactly as set_app_fields. Wire-invariant::schemaserves the same verbatim bytes as the owning overload.tableis a borrowed_fields_t, which converts implicitly from the array spellings a static table takes and NOT from astd::vector— so the erratum-1 lifetime tightening lands on a stale caller as a compile error rather than silently (ADR-0058 erratum 2). A runtime-sized table opts out viaborrowed_fields_t::unchecked.
-
void configure_remote_delivery_sink(remote_delivery_fn_t fn, void *ctx) noexcept¶
Install the sink the producer fan-out hands each REMOTE subscriber’s delivery to (#136, RFC-0004 §D/§E.1).
CONFIGURATION, not a runtime knob (#1049): install it at wiring time, from ONE thread, before frames flow. The verb is named
configure_to say so in the API rather than in a comment asking callers to be careful —tr::net::fwd_router_t’s constructor installs it, and a router constructed against a graph that is already serving frames is UNSUPPORTED. The sink then fires on whatever thread calls write (outside the vertex lock), and on subscribe for a transient-local latch. L4 keeps it as an opaque function pointer, so the graph never depends on a transport. A nullfn(the default) ⇒ remote slots are stored but never deliver. The value reaches the sink as a rope (ADR-0053 §6): a single-link value materializes zero-copy, a multi-link value is handed over as the rope it is.The pair is published through a tr::sink_slot_t, so violating the contract is DEFINED rather than undefined: a fan-out racing an install either sees the whole new pair, the whole old one, or no sink for that one edge — never a new
fnbeside a stalectx, and never the freed capture state thestd::functionpredecessor could hand it. What the slot does NOT do is stop a dispatch already in flight, soctxmust outlive every write that can still reach the fan-out.- Parameters:
fn – The sink;
ctxis handed back as its first argument. Null clears.ctx – Caller-owned context; must outlive every possible dispatch.
-
void configure_subject_resolver(subject_resolver_fn_t fn, void *ctx) noexcept¶
Install the pluggable subject resolver (ADR-0018) — the ACL enforcement switch.
No resolver (the default) ⇒ enforcement is DISABLED: every operation is allowed, exactly today’s behavior, and the hot path pays one null check. With a resolver installed, each gated operation with a NON-EMPTY caller context maps it through the resolver and — when a subject token comes back — evaluates the target vertex’s effective ACL (own ACEs + ancestor ACEs carrying INHERIT, ADR-0020): allowed iff some non-expired ACE with a matching subject (or
"EVERYONE@") grants the operation’s right bit; a vertex whose effective ACL is empty stays open (enforcement is opt-in per vertex via ACL presence). Denial returns status_t::PERMISSION_DENIED (tr::access::deniedon the wire, RFC-0002).The EMPTY caller context is the local-API convention and is trusted WITHOUT consulting the resolver (#905) — a remote op always carries its inbound link NAME, so it cannot spell the trusted context. The resolver’s own error arm is therefore free to mean DENY: an unresolvable caller is refused, not waved through.
The wildcard spelling is RESERVED against the resolver’s OUTPUT (#908): a token equal to
tr::graph::kEveryoneSubjectis not a principal — that caller is refused at every gate, guarded vertex or not — because the wire has one spelling for a subject token, so a resolver that passes a caller-supplied identity through could otherwise mint a principal indistinguishable from the wildcard ACE.CONFIGURATION, not a runtime knob (#1049): install it at wiring time, from ONE thread, before frames flow — which the verb’s name now says, and the
{fn, ctx}shape makes safe to get wrong. The gate reads the pair through atr::sink_slot_t: with no resolver that is ONE relaxed load, exactly what the null check cost, and with one installed the gate dispatches from a coherent snapshot, so a concurrent install/replace can neither pair a newfnwith a stalectxnor free the state a running resolver is standing on.ctxmust outlive every gated operation.- Parameters:
fn – The resolver;
ctxis handed back as its first argument. Null (the default) DISABLES enforcement.ctx – Caller-owned context; must outlive every gated operation.
-
void configure_subscription_observer(sub_observer_fn_t fn, void *ctx) noexcept¶
Install the EXTERNAL subscription observer — a callback fired on every
:subscribers[]mutation that arrived over a transport.The app-side answer to “who is watching what, right now”: a producer that wants to spin up a source only while a peer is subscribed, an inventory of live remote subscriptions, a projection of the fan-out graph. Today that is discoverable only by polling
read_subscribersover every vertex; this is the edge-triggered form.Fires from the ONE admission door every subscribe lands in (ADR-0049
admit_subscriber) and from the:subscribers[N]clear, so an append, a[N]replace (aREMOVEDfor the displaced edge then anADDED) and a clear are all reported, whichever wire shape carried them — the wire:subscribers[]APPEND that binds a REMOTE subscriber (subscribe_wire, target empty) and the one that names a LOCAL target alike.Only EXTERNAL mutations fire it — see sub_event_t for what that means and why. Two further silences are by design, not oversight:
evict_link_edges— the transport-plane hook that drops a departed link’s edges wholesale (RFC-0009 §D) — emits NOTHING. It is a local host API, not an op, and it clears k edges of one link in a batch. An app tracking live subscriptions must therefore treat its own link-down signal as the removal for every edge of that link.unsubscribe(subscription_t)is a local door and stays silent like the rest.
CONFIGURATION, not a runtime knob (#1049): install it at wiring time, from ONE thread, before frames flow — the
configure_remote_delivery_sink/configure_subject_resolvercontract, now stated by the verb and enforced by the{fn, ctx}shape rather than requested in a comment. A nullfn(the default) costs one relaxed load on the subscribe path and nothing anywhere else.ctxmust outlive every subscription mutation the graph can still report.- Parameters:
fn – The observer;
ctxis handed back as its first argument. Null clears.ctx – Caller-owned context; must outlive every reportable mutation.
-
void configure_wire_target_resolver(wire_target_fn_t fn, void *ctx) noexcept¶
Install the wire SUBSCRIBER target resolver (RFC-0021) — the transport plane’s mount descent, borrowed by the
:subscribers[]wire door.With no resolver (the default) a wire
SUBSCRIBER’sPATHchild is inert, which is every pre-RFC-0021 embedder’s behaviour, unchanged. With one installed, subscribe_wire asks it whether the target routes through a mount and, when it does, binds the edge to(that mount, the residual below it)instead of to the session the subscribe-write arrived on — which is what lets a third party wire a flow between two OTHER nodes and then depart (#491).CONFIGURATION, not a runtime knob (#1049):
fwd_router_tinstalls it in its constructor, so a node with a transport plane has it and one without cannot.- Parameters:
fn – The resolver;
ctxis handed back as its first argument. Null clears.ctx – Caller-owned context; must outlive every wire subscribe.
-
result_t<void> subscribe_wire(vertex_handle_t v, view_t source_view, view_t return_route, std::string link, view_t reverse_route = {}, std::string caller = {}, link_id_t link_token = {})¶
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 retiredadd_remote_subscriberparallel API.source_view(the SUBSCRIBER TLV, an owned copy) is parsed ONCE here — thedelivery_compactopt-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 ridesreturn_route(a view over a refcounted segment — the ONE copy of the route; every later delivery clones the refcount, ADR-0041 §2) overlinkvia the remote sink. Admission is the single ADR-0049 step: SUBSCRIBE gate onv's:aclunderlink(#81, ADR-0026,PERMISSION_DENIEDon denial) → slot append → durability latch (if the parseddelivery_policy_tsetsdurability_requestandvholds a value, the LKV is latched to this subscriber — one synchronous sink call, RFC-0004 §D / RFC-0022 §3.A).return_routeMUST be non-empty — an empty one isINVALID_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 aFWD{WRITE}with a zero-bytedston every publish. Both in-tree callers already satisfy this (the resolver rejects a failed route copy asBACKPRESSURE,fwd_router_t::subscribe_towardrefuses an empty residual asINVALID_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): aPATH_REFTLV 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 besidereturn_routeas the delivery optimisation + liveness check; empty (the default, and every pre-amendment caller) keeps the subscription canonical-only, byte-identical to before.calleris the SUBJECT this admission is gated under and the fan-in context every later delivery through the edge re-gates under — who subscribed, as againstlink'swhere 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’speer_handle_tpasses it here, and then the SUBSCRIBE gate, the storedsubscriber_remote_t::callerand 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 overlink(or, for a mount-routed target, over the mount).link_tokenis the CARRIED interned identity oflink(#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 alwayslink(a mount-routed target rebinds it to the mount’s, and afield_writeadmission has only itscaller, #943), and a token silently indexing under the wrong link is exactly the leaked subscriber edge #1071 exists to prevent.
-
result_t<value_ref_t> read(const path_t &path) const¶
Read by path — resolve the path key once (guarded map lookup), then the hot path.
A read whose path has a field tail (e.g.
:settings.app.kp,:subscribers[],:schema) is routed to the field surface.
-
result_t<void> write(const path_t &path, rope_t value)¶
Write by path — resolve the key once, then write(vertex_handle_t, rope_t,std::string_view).
-
result_t<value_ref_t> await(const path_t &path, std::chrono::nanoseconds timeout)¶
Await by path — resolve the key once, then await(vertex_handle_t,std::chrono::nanoseconds, std::string_view).
-
std::optional<vertex_handle_t> find(std::span<const std::byte> key) const¶
Resolve a canonical PATH-payload
keyto its vertex handle (nulloptif unknown).
-
std::uint32_t pin_payload_ratio(vertex_handle_t v) const noexcept¶
v'sdeclared RFC-0022 §3.D pin amplification ratioK(ADR-0042 §3); tr::graph::kPinNever (0) ⇒ this vertex never pins.The read accessor the opaque handle does not expose directly: the WRITE resolver (
op_resolve_walk.hpp) queries it here instead of dereferencing the vertex. One inline load, and nothing is inherited (RFC-0022 §3.F) — a vertex whose owner never called set_pin_payload_ratio answers 0 whatever its ancestors hold.
-
result_t<vertex_handle_t> ensure_vertex(std::span<const std::byte> key, std::string_view caller = {})¶
Find-or-create the vertex at
key(write-creates, RFC-0005).Resolves
key; when absent, creates the vertex — and every missing intermediate level,mkdir -pstyle, each a STORED_VALUE vertex — gated by the CREATE right on the nearest EXISTING ancestor’s effective ACL undercaller(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).keymust 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 unresolveddstanswers 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). Thevertex_tobjects themselves are still heap-allocated; that is the larger #873 arena question.
-
result_t<void> hide_from_enumeration(vertex_handle_t vh)¶
Drop
vhout 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 –
vhis null or is an unregistered placeholder — hiding a vertex that is not a member yet would silently apply to whatever registers there later.
-
std::uint64_t ancestor_walks() const noexcept¶
How many writes performed the ancestor (bubbling) walk — instrumentation.
The near-free-when-idle observable (RFC-0005): stays 0 while no subscriber exists above any written vertex, so tests and benches can assert a write never walks ancestors unless someone is listening. Relaxed monotonic counter.
-
std::uint64_t target_canonical_resolves() const noexcept¶
How many target-edge deliveries fell back to the canonical
find_ptrwalk 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_slotrefused it. Relaxed monotonic counter, and the observable an ablation uses to prove the bound leg is the one actually running.
-
delivery_drops_t delivery_drops() const noexcept¶
Snapshot the per-cause delivery-drop counters (delivery_drops_t).
The loads are individually relaxed and not one atomic snapshot, so a reader racing a delivering thread may see a torn total. That is deliberate: making it coherent would put a lock on the drop path to serve a diagnostic, and these are monotonic counters whose useful reading is “is this growing”, not an instant.
-
void count_external_drop(external_drop_t why, std::uint64_t n) noexcept¶
Count
ndeliveries 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.nis a delivery count, never an event count, exactly as for the internal sites: a deliverer that sheds N deliveries counts N. Relaxed monotonic; costs nothing when nothing is dropped.
Public Static Functions
-
static std::uint64_t deferred_release_drops() noexcept¶
How many retired
{ctx, release}pairs this PROCESS has dropped for want of a parking slot — each one a release hook that will never run (ADR-0080).The observability half of the bounded park: a leak is the safe answer to an exhausted bound, but a silent one is not. A non-zero reading means tr::graph::default_config_t::kDeferredReleaseSlots is undersized for how many subscriptions this node retires from inside a single delivery stack — raise it in the override fragment. It is process-wide (summed across threads) and monotonic, and it stays 0 forever on a node that never unsubscribes re-entrantly, which is most of them.
Under tr::graph::reclaim_qsbr_t it counts the SHARED retired table’s drops and means something sharper: that domain republishes and rescans before it gives up, so a non-zero reading says a participant thread genuinely never reached a quiescent state while the table filled. That is an embedder defect — a dispatching thread that never returns to its event loop, or more concurrent dispatchers than tr::graph::default_config_t::kQsbrParticipants — and this counter is how it surfaces.
-
static inline void thread_quiescent() noexcept¶
Declare that this thread holds no in-flight delivery — the OPT-IN quiescent point of a cross-thread grace period (ADR-0080, #1376).
No policy’s guarantee depends on an embedder calling this
, and that is deliberate: it would otherwise contradict ADR-0080 §Decision 4 and the reference article’s “there is no
verb the embedder must remember to call”. Every dispatching thread announces and drains AUTOMATICALLY at its outermost dispatch exit, which already covers ADR-0080 §Decision 3’s named case — an RX thread that has finished a message and returned to its event loop.
It exists for the thread the automatic path cannot reach: one that mutates LKV slots (displacing nodes onto its own private retired list) without ever dispatching, and one that wants a stated teardown precondition before an injected
std::pmrresource 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.cppis 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
SUBSCRIBERproduces, 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
:aclthat 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
assignwhose pending mark cannot be allocated sheds the vertex’s WHOLE subscriber set — each shed delivery is one increment, so1never stands in forN. 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 APIwrite, 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_DENIEDand 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).
-
std::uint64_t no_target = 0¶
-
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 characterspath::valid_segmentforbids, 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).
-
enum class external_drop_t : std::uint8_t¶
-
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_treturned bygraph_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_tkeeps 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 withgraph_tas its sole friend (#867, #1300), because every publish must passgraph_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 throughgraph_t::history/graph_t::drain_unflushed/graph_t::mark_flushedinstead.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
idxexists; 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.
-
enumerator OUT_OF_RANGE¶
-
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.
-
enumerator UNDECLARED¶
Public Functions
-
inline vertex_t(role_t role, path_key_t name, handlers_t handlers)¶
Construct a vertex with its role, own canonical NAME record (ADR-0057 — one segment, not the full key), and handlers. The cold extension block is allocated only if this identity needs one (#361 §1).
-
inline ~vertex_t()¶
Free the cold extension block (allocated at most once, ADR-0057 lifetime) and flush the edge block’s published + parked arrays (
edge_block_t’s destructor states the outlive-the-publishers contract that makes this safe).
-
inline 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’sbuild_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_censusplus the RFC-0022 host tests are its only callers. It used to be spelled by comparingsettings()’s returned ADDRESS against the sharedkDefaultSettingsconstant; RFC-0022 deleted both, so the question needs a name of its own rather than an idiom.
-
inline std::uint32_t pin_payload_ratio() const noexcept¶
This vertex’s declared pin amplification ratio
K(0 ⇒ never pin, the default).ONE inline load and nothing more: it is read on EVERY view-delivered write (
op_resolve_walk.hpp), so it may never become an ancestor walk. Nothing is inherited (RFC-0022 §3.F) — a vertex that was never given a ratio answers 0, whatever its ancestors hold.
-
inline const value_handlers_t &handlers() const noexcept¶
This vertex’s user handlers (Handler role behavior + the
on_childrenseam); an all-empty shared constant when no extension block.
-
inline std::function<void(std::string_view, const view_t&)> on_app_field_write()¶
A copy of this vertex’s owner apply seam (RFC-0010 §A.3), or empty when none — taken under the vertex lock so the caller can fire it OUTSIDE the lock (the seam may re-enter the graph). Empty ⇒ declared field writes just store.
-
inline vertex_t *parent() const noexcept¶
The owning parent node (
nullptronly 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_childrenmust 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>/connis a real, registered, addressable vertex —findresolves it, aSPEC/NAMEwrite executes on it,conn:schemareads 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 governsfind, retirement walks, the branch/leaf fork (has_registered_child) and the owner-sidefor_each_vertexcensus — is untouched, because RFC-0014 §6 makesread <module>/conn:schemathe 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 tookmap_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
fillandmark_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 areadagainst a concurrentregister_vertexno 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
recordbyte-for-byte, ornullptr— 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
fover 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);
fmust not mutate the tree.
-
template<typename F>
inline void for_each_descendant(F &&f)¶ Run
fover 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.cppwere 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.kMaxSegmentsis enforced only inpath_t::parse, the local string builder;graph_t::ensure_vertextakes 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
sortedlist already supports (the samelower_boundchild_by_recorduses). That costs O(log children) per ascent instead of the O(1) an explicit stack would give, and buys back an error channel the twovoidcallers could not have carried without a signature change.Note
Same contract as for_each_child — called under the graph’s map lock, and
fMUST NOT mutate the tree — the walk holds no snapshot and re-readssortedon every ascent, so an insertion mid-walk would move the position it is about to resume from.
-
inline void note_write()¶
Record a Handler-role write: bump the write sequence and wake awaiters (the vertex stores no value — the user handler consumed it).
-
inline std::shared_ptr<const rope_t> read_stored() const¶
The stored last-known-value (lock-free; null ⇒ never assigned / Handler role).
-
inline bool wait_for_change(std::uint64_t seq0, std::chrono::nanoseconds timeout)¶
Block until the write sequence moves past
seq0ortimeoutelapses.- Parameters:
seq0 – The current_seq snapshot the caller waits to see surpassed.
timeout – The maximum wait.
- Returns:
true iff a change was observed (
write_seq_ != seq0); false on timeout.
-
inline std::uint64_t current_seq() const¶
The current write sequence (bumped per assign — the await predicate base).
-
inline void mark_flushed()¶
Advance the STREAM drain cursor to “now” WITHOUT draining (RFC-0008 §E): an eager delivery already flushed the ring, so a later sweep must not re-deliver.
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-exceptionstr::detail::try_reservedegrades to probe-then-commit — the probe block is freed beforereservetakes one and a context switch in that window makes thereserveabort() the node (#850). The snapshot cannot take the ADR-0065tr::mem::block_array_tseam: its element is astd::shared_ptr, which the seam’s memcpy relocation would tear, andoutis 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_gapsignal of RFC-0025 §4.4/§4.5. Non-zero means entries the consumer would have seen are MISSING immediately before this batch. Written unconditionally when non-null, including on the zero-drain returns, so a consumer that polls a quiet ring still learns about a shed.
- Returns:
The number of entries drained (0 ⇒ nothing appended since the last flush, or the snapshot could not be allocated — retry on the next flush).
-
inline std::vector<rope_t> history_snapshot()¶
The STREAM ring contents, oldest first — each entry a rope clone (refcount bumps, no byte copy).
-
inline std::size_t ring_reserved_bytes() const¶
Total bytes this receiver currently holds RESERVED against its injected ring source — the byte bound’s observable (RFC-0025 §4.6.1 clause 3). Zero on a vertex that has never admitted an entry.
-
inline std::uint64_t ring_gap_count() const¶
Shed points on this receiver’s ring since registration — the cumulative
tr::flow::address_shift_gapcensus (RFC-0025 §4.4: a shed with no accounting is non-conforming).
-
inline std::size_t add_edge(subscriber_t s, edge_latch_t *latch = nullptr)¶
Append a subscription edge; atomically snapshot the transient-local durability latch when
latchis 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 intolatch, so a concurrentclear_edgecan 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.durabilityflag 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’sown_subs_seq_cst bump still precedes this whole verb, so ADR-0049’s Dekker pairing against a skipping publisher is byte-for-byte the one #708 landed. Displaced arrays are scanned AFTER the lock is dropped, on this thread.- Returns:
The occupied slot’s index (the
:subscribers[N]slot number), or kNoSlot when the edge array could not be allocated — nothing was admitted and the previously published array is untouched, so the vertex is unchanged.
-
inline bool clear_edge(std::size_t idx, void **retired_ctx = nullptr)¶
Deactivate the edge slot
idx(unsubscribe — a cleared:subscribers[N]).- Parameters:
retired_ctx – Optional out-parameter receiving the slot’s
callback_ctx— the one leg of a dispatch snapshot the library holds no owning copy of, so ADR-0080’s reclamation seam needs it back to hand to a release hook. Read UNDER the lock, before the shell displaces the slot, because after that the pair is gone. Written only on thetruereturn; left untouched when nothing was cleared.- Returns:
true iff the slot existed and was active (the caller then adjusts the RFC-0005 listener bookkeeping).
-
inline edge_replace_t replace_edge(std::size_t idx, subscriber_t s, edge_latch_t *latch = nullptr)¶
Replace the edge occupying slot
idx(RFC-0009 §D.1), snapshotting the transient-local durability latch under the SAME single lock hold as add_edge.§D.1 makes an indexed
:subscribers[N]write of aSUBSCRIBERreplace 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_viewsegment pin and coldremotehalf 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
idxis refused rather than back-filled with inactive shells: the index arrives off the wire, so growing on demand would let a peer allocate an arbitrary number of slots with a single:subscribers[65535]write. Slot indices are stable per §D.2, so a slot that does not exist yet is not addressable.- Parameters:
idx – The
:subscribers[N]slot number.s – The replacing edge.
latch – Optional durability latch; snapshotted iff the REPLACING subscriber requested durability (RFC-0022 §3.A) and the vertex holds an LKV.
- Returns:
Which case applied — see edge_replace_t.
-
inline std::size_t evict_link_edges(std::string_view link)¶
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::linkwhen it carries one, and otherwise itssubscriber_remote_t::caller— the two spellings the two admission doors leave behind for the SAME fact.subscribe_wire(theSUBSCRIBEop 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. Matchinglinkalone 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 remoteFWDarrived on, i.e. the same name spacelinkis 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’sdelivery_compactopt-in, leaves both spellings empty). An EMPTYlinkmatches 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 localdelivery_compactedge 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 wholesubscriber_remote_tblock 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 wholesubscriber_remote_tby refcount (ADR-0041 §2, #1448), so releasing the slot’s pin here never dangles a dispatch — the record it reads outlives this eviction by construction.- Returns:
The number of edges evicted (the caller unwinds exactly this many from the RFC-0005 listener bookkeeping).
-
inline std::size_t evict_route_edges(std::string_view link, std::span<const std::byte> route, bool bound_echo = false)¶
Reclaim the remote edges whose delivery
linkAND stored returnrouteboth match — the per-vertex half ofgraph_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. Matchinglinkalone 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 bytesdeliver_remoteemits as the deliverydst, which are the bytes the refusing hop echoes back — seereject_bus_name_hop’s swap). Onlysubscribe_wire-door edges qualify: a field-write edge stores no route, androutenever compares equal to its empty view. An EMPTYlinkorroutematches 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_REFa reverse-list delivery was refused as.bound_echo – True ⇔
routeis thePATH_REFform — 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 intoinline_buf— no heap allocation AND no dead stack zeroing per publish; a larger subscriber list reservesoverflowonce and fills it instead (thenoverflowis 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 unreservableoverflowdegrades the snapshot to the firstkInlineFanoutviews ininline_buf, and the rest of this delivery is dropped. It is TALLIED intodrops(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 firstdispatch_edge, so a subscriber callback that re-enters the graph always finds this thread’s cell empty (pin_tasserts 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 aseq_cststore to this thread’s own isolated cell, which is the whole reason a refcounted published array was rejected instead.Fallback: a thread that cannot claim a pin (more publishers than
kEdgePinSlots) copies the CURRENT array under the stripe mutex — safe because displacing an array requires that same lock. Correctness never depends on the constant; only scaling does.- Parameters:
inline_buf – The caller’s raw stack buffer (cleared on entry).
overflow – The heap fallback for large fan-out (cleared on entry).
drops – Out: what this snapshot SHED (snapshot_drops_t), zeroed on entry. By reference, not optional — a caller that may not see the shed count is the #896 defect itself.
- Returns:
The number of views snapshotted (into whichever buffer was used).
-
inline std::optional<view_t> edge_source(std::size_t idx)¶
The stored SUBSCRIBER TLV view of the active slot
idx(a:subscribers[N]read) — a refcount clone, no byte copy;nulloptfor a missing / inactive / TLV-less (in-process sugar) slot.
-
inline std::vector<view_t> edge_sources()¶
Every active slot’s stored SUBSCRIBER view, in slot order (the
:subscribers[]array read) — each a refcount clone.
-
inline value_handlers_t *revert_to_placeholder()¶
Restore this vertex to the state an unregistered PLACEHOLDER carries — the
unregistered ⇒ carries no stateinvariant retirement re-establishes (RFC-0009 §B.6). Clears everything a installs plus everything it leaves behind, so a later revive of this address inherits nothing of the retired owner: the value seam (swap-and-park, never freed — a lock-free reader may still hold the old pointer), the stored value and history, the:acl(own ACEs + the cached merge), the app-field table, the storage policy, the role, and the delivery mode. Survives by design:write_seq_(monotonic per address; a reset would regress the readiness cursors),listeners_above_(counts ANCESTOR subscribers, which retiring THIS vertex never touched — the graph adjusts it for cleared descendant edges), and the allocation / name / links (ADR-0057 insert-only — emptied, never freed or detached).Note
registered_is NOT touched here — it is map-lock state the graph flips. The caller MUST hold the graph map lock. This RETURNS the swapped-out value-seam block (or nullptr) rather than freeing it: a lock-free reader may still hold the old pointer, so the graph parks it and the embedder frees the park throughgraph_t::collect()(#576). The per-vertex stripe lock is taken internally.- Returns:
the detached seam block to park, or nullptr if this vertex had none.
-
inline void set_acl(std::vector<ace_t> aces)¶
Store this vertex’s
:aclas 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
:aclread re-encodes from here.
-
template<typename F>
inline auto with_acl(F &&f) -> decltype(f(false, std::declval<const std::vector<ace_t>&>()))¶ Run
fover this vertex’s whole:aclstate — 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_acllives a layer up and cannot be named from here), which is what makes an:aclread canonical: it serves a projection of the SAME listacl_allowsevaluates, 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.fmust not re-enter this vertex — the lock is held.- Returns:
Whatever
freturns.
-
template<typename F>
inline auto with_aces(F &&f) -> decltype(f(std::declval<const std::vector<ace_t>&>()))¶ Run
fover this vertex’s parsed ACE list under the vertex lock — the zero-copy evaluation accessor (graph_t::acl_allowshands the list to the pure ADR-0050 policy without snapshotting subject bytes per gated op).fmust 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
freturns.
-
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
:aclwrite 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_genis odd. When it is, that odd value and this vertex’s own parsed ACEs are SNAPSHOTTED andrebuildruns 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), thenevalruns.Race resolution (rebuild vs concurrent
:aclwrite): every invalidator — set_acl, the placeholder revert, the subtree mark an ancestor:aclwrite fans out (mark_acl_cache_dirty) — advances that one counter viainvalidate_acl_cacheafter 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 bydirty = false, pinning a stale merge as clean FOREVER (#880) — a revoked policy still enforced. A failed CAS also discards amergedthat 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
kAceInheritsubsequence, whichevalselects witheffective_acl_t::allows’srequired_flagsrather 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
evalreturns.
-
inline void set_app_fields(std::vector<app_field_t> table)¶
Install (or replace) the field descriptor table — the OWNER naming the holes in the closed
ENOTTYdefault (RFC-0010 §A.2), one more store-verbatim verb on this seam (theset_aclpattern).Replacement takes effect atomically with respect to concurrent field operations on this vertex (one lock hold). An empty
tableuninstalls — the vertex reverts to the closed surface, including the pre-RFC synthesized:schemashape — and, on a vertex that never had an extension block, allocates nothing (#361 §1: a leaf with no app fields pays nothing).
-
inline void set_app_fields_static(borrowed_fields_t table)¶
Install a BORROWED descriptor table (ADR-0058): the slots view the caller’s
tablestorage directly — zero declaration RAM. The array AND thename/descriptorbytes it points at MUST outlive the vertex (static/flash storage); borrowed_fields_t is what constrains the argument’s shape to match. Declaration only; values are written later via the field-write surface. Same uninstall-on-empty and allocate-nothing-on-empty-leaf semantics as set_app_fields.
-
inline std::optional<app_access_t> app_field_access(std::string_view name)¶
The declared access of the app field
name(nullopt⇒ undeclared — the graph’sSCHEMA_NOT_FOUND).
-
inline bool app_field_store(std::string_view name, std::span<const std::byte> bytes)¶
Store
bytesverbatim into the DECLARED app fieldname(RFC-0010 §D — bytes in, bytes out; no dtype/range validation, the descriptor is consumer self-description).- Returns:
false iff
nameis not declared (e.g. a concurrent table replacement removed it between the caller’s gate and this store).
-
inline app_read_t app_field_get(std::string_view name, std::vector<std::byte> &out)¶
Read the app field
nameintoout(the stored TLV bytes, verbatim);outis 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 /
:schemasnapshot (control-plane cold; empty ⇒ no table installed).
-
inline void set_history_depth(std::uint32_t keep)¶
Set the STREAM ring depth (RFC-0022 §3.C) — owner-side, never over the wire.
Allocates the extension block if this vertex has none (a STREAM vertex always has one already). Taken under the vertex mutex, which is the same lock the ring append re-reads it under, so a depth change and a concurrent store cannot interleave halfway.
- Parameters:
keep – Entries to retain; 0 is normalised to 1 by the ring trim.
-
inline std::uint32_t history_depth() const noexcept¶
The STREAM ring depth this vertex retains (1 when never declared).
-
inline void set_ring_source(tr::mem::block_source_t *src, bool reliable)¶
Bind this RECEIVING vertex’s own ring source and §4.4 pressure arm — the seam of RFC-0025 §4.6.1 clause 3, owner-side and with no wire surface.
Sited on
vertex_ext_t’s lazily-allocated ring block, never onvertex_titself: 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 — sosizeof(vertex_t)does not move,sizeof(vertex_ext_t)does not move either, and a vertex that never receives pays nothing. The #1285 ratchet and the RAM census are both untouched.REBINDING DRAINS. Reservations are released to the source that served them (sized reclaim), so a rebind first hands every queued entry’s block back to the OLD source and empties the ring; the queue restarts on the new budget. Wiring-time by intent — the “configure before frames flow” contract
set_history_depthandset_delivery_modealready carry.- Parameters:
src – The source this receiver’s admissions are charged against.
nullptrunbinds, so the next admission re-resolves the graph-level default.reliable – The §4.4 arm: false (default) best-effort — shed oldest, account the loss, raise a gap; true reliable — refuse the admission and answer the local producer
BACKPRESSURE, shedding nothing.
-
inline tr::mem::block_source_t *ring_source() const noexcept¶
This receiver’s bound ring source, or
nullptrwhile it still draws the graph-level default (nothing admitted and nothing declared).
-
inline void set_pin_payload_ratio(std::uint32_t k)¶
Set the RFC-0022 §3.D pin amplification ratio
K(ADR-0042 §3) — owner-side, never over the wire. 0 (tr::graph::kPinNever) never pins.Published under the vertex mutex; the write-path reader (pin_payload_ratio) takes no lock, because a ratio changing under a concurrent write only decides WHICH correct store shape that write takes.
-
inline delivery_mode_t delivery_mode() const noexcept¶
How this vertex participates in an ANCESTOR’s propagate sweep (RFC-0008 §C).
Relaxed, and deliberately racy against a concurrent
set_delivery_mode: the assign path reads it lock-free as a FAST PATH only (graph_t::mark_pending), and whichever of the two values it observes there, the decision that actually places the vertex in a sweep set is re-taken under the graph’s sweep lock. ATOMIC becauseset_delivery_modemay 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 (seemark_unregistered), so a plainboolis 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
:aclwrite 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_edgeson 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 coveringpropagatedelivers 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
storealready documents forwaiters. PUBLISHER: store the LKV, THEN load this count. SUBSCRIBER: bump this count, THEN load the LKV into the latch. Both sidesseq_cst, so they share one total order: a publisher that reads zero is ordered before the subscriber’s bump, hence before the subscriber’s latch load — so the latch carries the value the skipped fan-out would have delivered. The other interleaving (count already bumped, slot not yet appended) costs one pointless lock acquisition that snapshots nothing, never a lost delivery.Both halves take
kDeliverySkipOrder, which a weakly-ordered target (tr::graph::kWeaklyOrdered)static_asserts is stillseq_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
_orderedtwin (#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 theseq_cstcandidate doubled the idle write’s rv32 fence count to exclude nothing.
-
inline void bump_listeners_above(std::int32_t delta) noexcept¶
Adjust the ancestor-listener count by
delta(an ancestor’s edge came/went).
-
inline void init_listeners_above(std::uint32_t count) noexcept¶
Seed the ancestor-listener count at creation (the newborn’s O(depth) sum).
Public Static Attributes
-
static constexpr std::size_t kInlineFanout = edge_snapshot_t::kCapacity¶
The no-heap small-fan-out snapshot width (snapshot_edges buffer size).
-
static constexpr std::size_t kNoSlot = static_cast<std::size_t>(-1)¶
What add_edge answers when the edge could NOT be admitted — the injected resource is exhausted (#477: the writer soft-fails by value; a
bad_allocunder-fno-exceptionswould be anabort(), and admission is reachable from a peer’s bytes since RFC-0014).A caller that sees it must NOT count a listener: nothing was appended and nothing was published, so the vertex is exactly as it was.
-
struct snapshot_drops_t¶
What a snapshot DECLINED to hand back: the deliveries a vertex shed before the graph could dispatch them (#896).
snapshot_edgesis 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 whilegraph_t::delivery_drops(), the one observable, read zero.vertex_towns no counters (it is the storage layer, not the instrumentation layer): it reports the tally by reference andgraph_t::fan_outfolds 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_memorycounted the edges whose owning link / caller copies could not be allocated. edge_view_t no longer copies them — it takes a refcount share of the immutable cold half — so the per-edge snapshot reaches no allocator on any edge shape and cannot fail. What remains is the capacity degrade: a fan-out wider than the inline snapshot, on a heap that would not lend it a buffer. The OUT_OF_MEMORY delivery cause is untouched and still counted from the legs that can still hit it (graph_t::dispatch_edge_target’s rope clone, the store legs).Public Functions
-
inline bool any() const noexcept¶
Did this snapshot shed anything? The ONE test a clean fan-out pays.
Public Members
-
std::uint32_t truncated = 0¶
Edges past the inline prefix, abandoned because the overflow buffer for a wide fan-out could not be reserved — the capacity degrade.
-
inline bool any() const noexcept¶
-
struct store_drops_t¶
What a
storeSHED 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_tis the storage layer and owns no counters, so it reports the tally andgraph_tfolds 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 whilegraph_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_gappoint; silence here is the one behaviour the RFC forbids.
-
inline bool any() const noexcept¶
-
enum class edge_replace_t¶
-
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 whysnapshot_edgesmay load it with a plain acquire and no pin at all.slots is the MASTER: the
:subscribers[N]slot table, index-stable per RFC-0009 §D.2, mutated only under the vertex stripe mutex exactly as it was before #635. pub is the dispatch-side projection of it that publishers read without any lock.Public Functions
-
inline ~edge_block_t()¶
The teardown flush: free the published array AND everything still parked.
The contract this states, in the same shape ADR-0069 §6 states for the LKV domain: the graph — and therefore every vertex — must OUTLIVE the threads that published through it. A thread still inside
snapshot_edgeswhen its vertex is destroyed is a use-after-free with or without this mechanism, and the ASan/LSan legs exercise the flush on the joined-threads side of that line.
Public Members
-
std::vector<subscriber_t> slots¶
The master slot table (stripe-locked).
-
std::atomic<edge_pub_t*> pub = {nullptr}¶
The published array (null ⇒ no edges).
-
std::atomic<edge_pub_t*> retired = {nullptr}¶
Displaced arrays awaiting a scan.
-
inline ~edge_block_t()¶
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_talready held a lazy pointer for the ring’s entries; hanging the source, the arm and the two counters off that same pointer keepssizeof(vertex_ext_t)EXACTLY where it was, so a vertex with app fields, an:aclor a handler — which allocates the cold block for reasons of its own and will never admit a stream entry — pays zero additional bytes. The RAM census (vertex_app5,vertex_app5_static,reg_escape) is the gate that says so, and it caught the four-loose-members spelling of this at +32 B.Public Functions
-
inline void release_all() noexcept¶
Release every held reservation and empty the ring — the ONE place the charge/release pairing is closed, shared by the destructor, the placeholder revert and
graph_t::set_ring_source’s rebind. Idempotent.
-
inline ~ring_state_t()¶
Hand every reservation back before the block dies. Dropping the deque without releasing them would leak the whole ring’s byte budget on every teardown.
Public Members
-
std::deque<ring_entry_t> entries¶
The queued entries, oldest first, each holding its admission reservation.
-
tr::mem::block_source_t *source = nullptr¶
This receiver’s OWN injected source — the seam admissions are charged against.
Null until the first admission or an explicit `graph_t::set_ring_source`, at which point the graph-level default (itself defaulting to `tr::mem::heap_source()`) is BOUND here and stays bound: the destructor and every trim release against this exact source, and a sized reclaim cannot be served by a source that did not hand the block out. Per-injection-point, never a shared pool — ADR-0079's amendment measured a folded source collapsing to 0.01x of its own single-thread rate at T=24.
-
std::uint64_t gaps = 0¶
Shed points on this ring since it was created — each one a
tr::flow::address_shift_gap(RFC-0025 §4.5: “a detected discontinuity in an
ordered flow”), surfaced to the consumer IN ORDER through
vertex_t::drain_unflushed’s gap out-param and kept here for the census.
-
std::uint64_t gaps_drained = 0¶
How much of gaps a consumer has already been told about, so
vertex_t::drain_unflushedreports 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;trueis RELIABLE — the admission is refused outright and the local producer is answeredBACKPRESSURE, with nothing shed and no growth past the byte bound. Declared owner-side throughgraph_t::set_ring_source; it is NOT a new knob on the wire — RFC-0025 §4.4 selects the arm from the subscription’s existingreliabilitybits, and this is where a receiver states its own.
-
inline void release_all() noexcept¶
-
struct ring_entry_t¶
One entry of a receiving vertex’s STREAM ring: the value, plus the RESERVATION it was admitted under (RFC-0025 §4.6.1 clause 3).
The reservation is the whole point, and the thing most easily misread. Admission calls
tr::mem::block_source_t::try_alloc(retained_bytes)on the RECEIVING vertex’s own source and holds the block until the entry retires (trim, drain-past, revert, destruction), at which point it is released. That bounds admission, in bytes, against a budget the receiver injected.It does NOT bound PLACEMENT. The payload never moves: value stays exactly the
shared_ptrthe publish handed out, in whatever allocator the value backend gave it, so the zero-copy handoff is preserved and a ring append is still a refcount bump. Physical placement migration is the later #873 family, explicitly out of scope here. A reader who assumes the ring’s bytes physically move into the injected source will be wrong, and the wrongness is expensive.Public Members
-
std::shared_ptr<const rope_t> value¶
The published value — a refcount share of the LKV, never a byte copy.
-
void *token = nullptr¶
The admission reservation, or
nullptrfor an entry admitted at zero cost. Released with bytes and kAlign, the sized-reclaim contract.
-
std::size_t bytes = 0¶
The reserved width, as passed to
try_alloc— required to release it.
-
bool gap_before = false¶
True iff a shed happened immediately BEFORE this entry: the in-order
tr::flow::address_shift_gapmarker 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.
-
std::shared_ptr<const rope_t> value¶
-
enum class tr::graph::role_t : std::uint8_t¶
A vertex’s behavioral role (docs/reference/11 §roles). Byte-wide: it packs into
vertex_t’s flag byte group (#361 diet — 3 values need no int).Values:
-
enumerator STORED_VALUE¶
Role 1: last-writer-wins; holds the last-written value.
-
enumerator STREAM¶
Role 2: the CONSUMER’s bounded history ring — a queue the RECEIVING vertex owns (RFC-0025 §4.6.1 Amendment 2: “a producer
never queues”), bounded in BYTES by that vertex’s own injected
tr::mem::block_source_tand retained to a depth declared owner-side bygraph_t::set_history_depth(RFC-0022 §3.C).
-
enumerator HANDLER¶
Roles 3-7: user
on_read/on_writesupplies the behavior.
-
enumerator STORED_VALUE¶
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_tdata op (read / write / await / assign / propagate / subscribe / history / field-write). Pointer-sized and trivially copyable, so it loads and passes exactly like thevertex_t*it replaces — identical codegen — but it exposes nooperator*or raw-pointer accessor: avertex_tis opaque L4 state, never dereferenced by callers. Constructed ONLY by graph_t (thefriend), 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 thestd::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.)
-
inline friend bool operator==(vertex_handle_t a, vertex_handle_t b) noexcept¶
-
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_tis the solefriend— 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 thevertex_tbehind 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 aNOT_FOUNDno-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 anedge_view_tsnapshot 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.)
-
subscription_t() = default¶
-
struct subscriber_t¶
One subscription edge (M3b).
A write to the owning vertex fans out to a target vertex (target_key — spec-faithful re-dispatch) and/or an in-process callback (sugar), per docs/reference/02 §dispatch + 04 §write fanout. An inactive slot models an unsubscribe (a cleared
:subscribers[N]). The wire/gate members live in the lazily-allocated remote half (#380 §3), so the plain in-process edge costs 80 B, not 160.Public Functions
-
subscriber_t() = default¶
A blank edge (an inert slot shell, or a door’s scratch record).
-
subscriber_t(const subscriber_t&) = delete¶
NOT copyable, exactly as it was while the cold half was a
std::unique_ptr(#380 §3). The handle that replaced it IS copyable — that is the point — so the ban is stated rather than inherited: a copied slot would share a cold half that ensure_remote is then entitled to write.
-
subscriber_t &operator=(const subscriber_t&) = delete¶
NOT copy-assignable — see the copy constructor.
-
subscriber_t(subscriber_t&&) = default¶
Movable: what the slot verbs do (append, reuse, reclaim-in-place).
-
subscriber_t &operator=(subscriber_t&&) = default¶
Move-assignable:
subs[idx] = std::move(...)is the reclaim.
-
~subscriber_t() = default¶
Releases this slot’s reference to the shared cold half.
-
inline subscriber_remote_t &ensure_remote()¶
The cold half, allocated on first use (admission-time only — never on a dispatch path), and MUTABLE only because the caller is still its sole holder.
The build phase of build-then-freeze (#1442). Every in-tree door fills a stack-local subscriber_t and only then hands it to
vertex_t::add_edge/replace_edge, so nothing has cloned the handle yet; the assertion states that rather than trusting it, because a write reached after a publish would mutate bytes a pinned reader may be copying under no lock.
Public Members
-
target_key_t target_key¶
Canonical PATH key (null ⇒ callback-only).
-
target_binding_t binding = {}¶
Minted slot for target_key (#830).
-
subscriber_fn_t callback = nullptr¶
In-process sink fn; null ⇒ target-only (ADR-0053 §6 rope value).
-
void *callback_ctx = nullptr¶
Caller-owned context passed back to callback; must outlive every delivery.
-
view_t source_view = {}¶
The original SUBSCRIBER TLV view this slot was written from, retained zero-copy (a refcount clone of the field-write payload).
Empty for in-process callback sugar that carries no TLV (the local target sugar DOES carry one — ADR-0049 encodes through the field-write door). A
:subscribers[]read ropes these slot views into theFWD{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 coldremotehalf:durability_requestis 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).
-
subscriber_t() = default¶
-
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::remotenull 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 firstvertex_t::try_publish_edgesonward 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_ofand 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_tper pre-existing entry, per admission — one nothrowoperator newplus up to twostd::stringheap copies each — andscan_retired_edgesfreed 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_remotesince #1448;vertex_t’s published-entry copy before it) reads this record instead of a published copy, and keeping the offsets it reads at exactly where they were is what kept that loop’s instruction stream identical across #1442. The slot-side readers (edge_view_of,evict_link_edges,evict_route_edges) move their displacements instead — control-plane paths, none of them pinned.
-
view_t return_route = {}¶
The consumer’s accumulated return route (a complete PATH TLV’s bytes — the FWD
srcthe 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_edgetakes 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_wirerefuses an empty route asINVALID_PATH(#1055), and the:subscribers[]field-write arm, which binds no route, deliberately leaves link empty (see the note at that door: assigning a link there would manufacture exactly the routeless delivery this invariant excludes). So on a published edge the two are populated together or not at all, and testing either one answers “is this subscriber remote?”. Held as a view over a REFCOUNTED segment (ADR-0041 §2): copied once at subscribe, then every delivery snapshot is a refcount clone — O(1) copies over the subscription’s life, and an in-flight delivery keeps the route alive across a concurrent unsubscribe. An opaque view, so L4 never depends on tr::net.
-
view_t reverse_route = {}¶
The COMPLETED reverse-direction bound route (RFC-0024 §7.1 amendment 1) — a
PATH_REFTLV 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_wirewhen 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 elements1..on the wire as the delivery’s bounddst. 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
:aclWRITE 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 (itsFWD{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
sizeofstays the pinned 120 B. That is why the shape is an intrusive count and not astd::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_edgesat 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_tis the in-tree primitive tr::view::segment_ptr_t already uses,LIBTRACER_NO_ATOMICfallback included.
-
std::string link¶
-
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
activebit 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_ctxdown 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 thestd::unique_ptrhere was) while making a republish’s per-entry cost a pointer copy and an increment instead of a heap allocation and twostd::stringcopies.
-
std::atomic<bool> active = {true}¶
Monotone true -> false liveness bit.
-
subscriber_fn_t callback = nullptr¶
-
using tr::graph::subscriber_fn_t = void (*)(void *ctx, const rope_t &value)¶
The in-process per-edge delivery sink: a plain
{fn, ctx}pair (the ADR-0047 hot-path shape, same doctrine astr::net::receiver_slot_t).Snapshotting one under the fan-out lock is a trivial copy — no per-publish
std::functioncopy (which heap-allocates once captures exceed the SBO). The value crosses as the rope it is (ADR-0053 §6); the sink may clone links (refcount bumps).
-
using tr::graph::subscriber_release_fn_t = void (*)(void *ctx)¶
The hook a caller hands graph_t::unsubscribe so the LIBRARY can signal it that the retired subscription’s context is dead.
The direction is the whole point of ADR-0080 §Decision 4: the embedder never polls in-flight state and never waits. It registers this, and libtracer calls it exactly once, on the caller’s own thread, outside every graph lock, at the policy’s grace point. A contract of the form “the callback may still be invoked until you call X” is what that decision rejects.
- Param ctx:
The
callback_ctxthe 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_tdelivery_compact opt-in. The injected sink (atr::netconcern — graph_t::configure_remote_delivery_sink) interprets these: it maps link to a transport child and emits a full-routeFWD{WRITE}or, when delivery_compact, an auto-promoted labelCOMPACT(RFC-0004 §D/§E.1). link is borrowed for the sink call only; return_route is a refcount clone of the stored route segment (ADR-0041 §2) — the sink may rope it into an egress frame, and it stays alive across a concurrent unsubscribe.Public Members
-
std::string_view link¶
This node’s NAME for the consumer link.
-
view_t return_route¶
Consumer return route (PATH TLV view, refcount clone).
-
view_t reverse_route¶
Completed reverse bound route (
PATH_REFview, 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.
-
std::string_view link¶
-
struct sub_event_t¶
One EXTERNAL mutation of a producer’s
:subscribers[]— what graph_t::configure_subscription_observer reports.“External” is exactly the ADR-0018 caller context being NON-EMPTY: the op arrived through
op_resolver_tcarrying 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 — bothsubscribe()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
PATHpayload, 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
Public Members
-
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’sPATHchild — WHAT the record says, verbatim.EMPTY when the record carries no well-formed
PATHat 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_wireresolves 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).
-
wire::key_view_t producer¶
-
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 astd::function(#1049) — seesubject_resolver_fn_tfor why.ctxis caller-owned and must outlive every subscription mutation the graph can still report.Warning
Runs SYNCHRONOUSLY on the resolver’s thread, inside the operation it reports, and the reply is not assembled until it returns — so it must be cheap and non-blocking, and it MUST NOT re-enter
graph_t. It is called outside every graph lock (the admission door has already released the vertex stripe lock and the map lock), so a re-entrant call does not self-deadlock; it is refused on the simpler ground that an observer which mutates the graph while a:subscribers[]write is mid-flight makes the event stream depend on its own side effects. Deferral — queueing the event and acting on it from the app’s own task — is the APP’s job, exactly as it is for graph_t::configure_remote_delivery_sink.
Handlers and delivery policy¶
-
struct write_ctx_t¶
The per-call context a write carries into a HANDLER’s
on_write(#375).A HANDLER is the one seam where application code REACTS to a write, so it is the one seam that needs to know WHO wrote. The graph already resolved that identity one stack frame earlier — the ACL gate (
graph_t::acl_allows) runs immediately before the handler, on the same value — so this type hands the handler the datum the gate just used rather than making it re-derive one. It is the ACL subject-table integration point (ADR-0018 — authorization over a pluggable subject token; ADR-0082 for why the subject is a claim of its own and not a spelling ofpeer_named): a handler that keys its own policy off subject keys it off exactly what the vertex’s:aclwas 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.appnamespace 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 momenton_writereturns. Stashing thestring_viewin a member, a map key, or a queued work item is a DANGLING read, not merely a stale one. Take astd::string(or the token’s bytes) if the identity must outlive the call.Public Functions
Public Members
-
std::string_view subject¶
The resolved SUBJECT token of the writer — the ACL model’s
subject → rightsprincipal (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_allowsshort-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 anOWNER@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.
-
std::string_view subject¶
-
struct handlers_t¶
User behavior for a Handler-role vertex.
on_childrenadditionally applies to ANY role: when set, a read of the vertex’s:children[]field serves this synthesized member listing (a complete POINT TLV view) INSTEAD of enumerating registered child vertices — the ADR-0044 seam by which a transport/connection vertex lists its live bus peers without ever creating a vertex for them. The value seam is rope-typed (ADR-0053 §6):on_readsupplies the vertex value as the rope it is (a contiguous scalar is the single-link case),on_writereceives the written value without a flatten copy.Public Members
-
std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write¶
Receives the written value and the writer’s write_ctx_t (#375). Both arguments are borrowed for the call only — copy if retained.
-
std::function<void(std::string_view name, const view_t &value)> on_app_field_write¶
The owner apply seam (RFC-0010 §A.3): fires after a declared
:settings.app.<name>field write stored its bytes, with the field’s key (belowsettings.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 wakesawaitand never propagates). Unset ⇒ the bytes just store (a passive metadata field).
-
std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write¶
-
struct value_handlers_t¶
The internal, lazily-allocated STORAGE of a vertex’s VALUE seam (ADR-0058 Step 2) — the three seams
handlers_tcarries minuson_app_field_write.Split off from the public handlers_t input so a vertex that installs none of the three never allocates these ~96 B of
std::function: the block lives behind a lazily published pointer in the extension block, null unless at least one ofon_read,on_write,on_childrenwas given. Allocation is keyed on that PRESENCE, not onrole_t—adopt_identitynever consults the role — so aSTORED_VALUEvertex given anon_children(the/net/<module>/<name>identity vertex of a bus link) does carry one, and aHANDLERvertex registered with an empty handlers_t does not. Which of the three is ever CONSULTED is a separate, per-seam question:on_read/on_writerun only on a HANDLER-role target, whileon_childrenserves the synthesized listing whatever the role.on_app_field_writeco-occurs with app fields, not the value seam, so it moved to app_field_group_t. Set once at registration (vertex_t::adopt_identity), read lock-free thereafter.Public Members
-
std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write¶
Receives the written value and the writer’s write_ctx_t (#375). Both arguments are borrowed for the call only — copy if retained.
-
std::function<result_t<void>(const rope_t&, const write_ctx_t&)> on_write¶
-
struct delivery_policy_t¶
One subscription’s DELIVERY policy — a packed 16-bit field carried in the
SUBSCRIBERTLV’sSETTINGSchild (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
reliabilityorpriorityto 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:
0is conflate, which is what every pre-RFC-0025 subscriber wrote into those bits when they were reserved. Old subscribers are conflate-class BY CONSTRUCTION.Only durability_request is consumed today (the transient-local latch at
graph_t::admit_subscriber); reliability, priority and delivery_class are stored and read back, awaiting the fan-out-edge and receiver-ring work that honours them — the honest shape RFC-0022 §3.E chose over moving dead per-vertex fields.Flags only, never a magnitude. A deadline or a queue bound added later is a magnitude and belongs in the subscription’s cold half as a full-width field, never in these bits.
Public Functions
-
inline constexpr std::uint8_t reliability() const noexcept¶
0 = best-effort, 1 = reliable (2–3 reserved; stored, never interpreted).
-
inline constexpr std::uint8_t priority() const noexcept¶
0–7, 0 = default.
-
inline constexpr bool durability_request() const noexcept¶
True iff THIS subscriber asked for the latched last value on join.
-
inline constexpr delivery_class_t delivery_class() const noexcept¶
Bits 6–7 — how the fan-out edge treats this subscriber’s deliveries (RFC-0025 §4.1).
Every two-bit pattern is an assigned class, so this accessor is total: there is no “unknown class” to reject, and a word from a future sender still decodes to one of the four. Reading the field is not honouring it — the classes beyond
CONFLATEland 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.
-
inline constexpr std::uint8_t reliability() const noexcept¶
-
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).
assignand a DIRECT propagate on the vertex itself are never gated by it. Held as vertex state (default IF_NEWER); wire config via the vertex:settingsis 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.
-
enumerator IF_NEWER¶
-
class value_ref_t¶
An owning reference to a vertex’s PUBLISHED value — what graph_t::read and graph_t::await hand back.
The value a vertex publishes is already refcounted: the LKV slot holds it as a
std::shared_ptr<const rope_t>, and the policy contract inlkv_slot.hppfixes that shape becauseload()must return an OWNING handle. A read therefore has a choice — hand the caller that reference, or copy the rope out of it. Copying is not free: a rope copy clones onesegment_ptr_tper link, and each clone is a contended refcount RMW on a line every reader of that vertex shares, so it costs more as links grow AND as readers grow.Measured on the real path, both arms alternating inside ONE binary (24-thread host, 102 paired samples): median 1.37x aggregate, 89/102 samples favouring the reference, and p50 improving most where it hurts most — 2,104 ns to 1,193 ns at sixteen readers on one shared vertex. The composed BRANCH read, which must build a value rather than share one, measured 1.00x (15/30): the shape that cannot benefit does not pay either.
The rule this draws: a read of a PUBLISHED value returns a reference to it; a read that COMPOSES a new value returns the value. That is why graph_t::read_children_folded and its siblings still return a
rope_t— there is no published object for them to reference.Holding one keeps that value alive, exactly as the reader’s own reference did before. Under an injected
std::pmr::memory_resourcethat is a real obligation: the value was allocated from the graph’s resource, so an outstanding reference pins it (ADR-0069, deferred reclamation).Public Functions
Wrap a published value’s handle.
-
inline const rope_t &operator*() const noexcept¶
The referenced value. Undefined if this reference is empty.
-
inline const rope_t *operator->() const noexcept¶
Member access on the referenced value.
-
inline const rope_t *get() const noexcept¶
The referenced value, or null.
-
inline explicit operator bool() const noexcept¶
Whether this reference names a value.
Public Static Functions
-
static inline value_ref_t composed(rope_t &&r)¶
Take ownership of a freshly COMPOSED value, giving it a published value’s shape.
The composed branch read builds a rope no vertex published; this is what lets it answer the same signature. It allocates a control block, which the published path does not — measured neutral (1.00x over 30 paired samples), because a subtree walk dominates it.
Edges¶
-
struct edge_view_t¶
The edge record’s WIDTH, pinned (#1266).
Both halves are per-edge storage on a node whose subscriber arena is measured in kilobytes, and the hot half is what the fan-out loop streams — #380 §3 split the cold members out for exactly that reason, and the split is only worth anything while the hot record stays narrow. These numbers moved silently more than once (a member added to the wrong half costs nothing a test can see until the RAM census runs), so they are stated where a change to either struct has to walk past them.
64-bit hosts only: the widths are pointer-sized-member sums, so an MCU build legitimately differs and a
sizeofpin 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_ptrwas, 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 ownstd::stringcopies 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_ptrclone and an intrusive increment do not), sovertex_t::copy_publishedno longer has a per-edge OOM leg at all and vertex_t::snapshot_drops_t no longer carries anout_of_memorycount — 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_tclones; 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
kCapacityof these on the publishing thread’s stack, and a wide fan-out streamsFof them through the overflow vector, which is theF * sizeof(edge_view_t)termbench/bench_common.hppnames 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::stringmember 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_edgetakes 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 whatlink.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.
-
inline std::string_view link() const noexcept¶
-
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 eightrep stosblocks 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
vas 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-thsnapshotted view (i< size).
-
inline const edge_view_t &operator[](std::size_t i) const noexcept¶
The
i-thsnapshotted view (i< size), const.
Public Static Attributes
-
static constexpr std::size_t kCapacity = 8¶
The snapshot width (mirrored as
vertex_t::kInlineFanout).
-
edge_snapshot_t() noexcept = default¶
-
struct edge_latch_t¶
The dispatch snapshot’s WIDTH, pinned — the delivery path’s bandwidth (#1448).
snapshot_edgeswrites one of these per active edge on every fan-out,kInlineFanoutof them live on the publishing thread’s stack, and a wide fan-out streamsFthrough 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 (twostd::strings and twoview_ts inline), 48 B after.A transient-local durability latch (RFC-0004 §D / Q4): the LKV plus the freshly admitted edge’s dispatch view, both snapshotted atomically with the append.
value stays null when no latch fired (the subscriber requested no durability — RFC-0022 §3.A — or the producer holds no LKV yet).
Public Members
-
std::shared_ptr<const rope_t> value¶
The latched LKV; null ⇒ no latch.
-
edge_view_t edge¶
The admitted edge’s dispatch view.
-
std::shared_ptr<const rope_t> value¶
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/woconstrain 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.
-
enumerator RO¶
-
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
:aclre-encodes from its parsed projection, #907): dtype/range validation is the owner’s, in its apply seam (handlers_t::on_app_field_write) — the runtime validates only addressing (declared / undeclared, writability): one table lookup.Public Members
-
std::string name = {}¶
The field’s key below
settings.app.— a.-joined spelling of the field steps ("kp","wifi.ssid"); the runtime keys the joined string flat.
-
app_access_t access = app_access_t::RO¶
Owner-declared remote writability.
-
std::vector<std::byte> descriptor = {}¶
The §B.1 descriptor record members (dtype/unit/min/max/label…, concatenated child TLVs) served inside this field’s
:schemaentry VERBATIM, after the runtime-projectedaccessmember. 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.
-
std::string name = {}¶
-
using tr::graph::app_field_static_t = app_field_slot_t¶
The install-time spelling of app_field_slot_t — the same type. Kept as a name because it reads better at an owner’s
set_app_fields_staticcall site, and because it is the spelling already in the wild (docs, integrations, firmware tables).
-
struct app_field_slot_t¶
One app-field DECLARATION (ADR-0058, class ②): view-shaped, owning nothing.
Unlike app_field_t this owns NOTHING:
nameanddescriptorare VIEWS. For an OWNING install they point into app_field_table_t::backing; for a BORROWED install (graph_t::set_app_fields_static) they point at the caller’s own storage, and the caller guarantees the pointed-to bytes — and the array holding these entries — outlive the vertex. Pass static storage (flash /.rodata), never a stack array or a soon-freed heap block. Either way the storage is immutable for the table’s lifetime, so the views stay valid. Declaration only: no initial value (write values after install via the field-write surface).This is ONE type serving both roles. It used to be two —
app_field_static_tfor the install-time shape andapp_field_slot_tfor the runtime’s copy of it — which were field-for-field identical, so a borrowed install spent an allocation and a copy converting between them. Unifying them lets a borrowed table be viewed in place (ADR-0058 erratum 1).Public Members
-
std::string_view name¶
Field key below
settings.app.(§A.1).
-
app_access_t access = app_access_t::RO¶
Owner-declared remote writability.
-
std::span<const std::byte> descriptor = {}¶
§B.1 descriptor bytes, served verbatim.
-
std::string_view name¶
-
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_writeco-occurs with the field table (it is the table’s apply seam), NOT with the vertex’s value seam — so it lives here, not in value_handlers_t. A vertex with no app fields and no apply seam keeps this group null and pays neither the table nor the ~32 Bstd::function. Allocated on the first of eitherset_app_fields*(the table) or anon_app_field_writeat registration; guarded by the vertex mutex, insert-only (never freed before the vertex).Public Members
-
app_field_table_t table¶
The view-slot descriptor table + lazy value store.
-
std::function<void(std::string_view name, const view_t &value)> on_app_field_write¶
The owner apply seam (RFC-0010 §A.3): fires after a declared field write stored its bytes, OUTSIDE the vertex lock. Unset ⇒ bytes just store.
-
app_field_table_t table¶
-
struct app_field_table_t¶
A vertex’s RFC-0010 field descriptor table (ADR-0058): the immutable declaration (class ②) split from the per-vertex mutable values (class ③).
Both install overloads converge here.
set_app_fields_staticleavesbackingempty 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 — thevertex_app5_staticandvertex_app5gate 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 owningset_app_fieldspacks the runtime table’s name+descriptor bytes intobacking— ONE allocation for the whole table — and points the slots into it.backingis never mutated or reallocated whileslotsreference it (a re-install replaces the whole table under the vertex mutex).Public Members
-
std::span<const app_field_slot_t> slots = {}¶
Per-field declaration views, in owner install order. Empty ⇒ no table installed (the closed
ENOTTYdefault). Guarded by the vertex mutex.A SPAN, not a container: a borrowed install points it straight at the caller’s array and allocates nothing for the declaration, which is what ADR-0058 §Step 1.2 promised and did not deliver (it copied into a
std::vector— see erratum 1). An owning install points it at owned_slots. Stable across the table’s moves for the same reasonbackingis: a movedunique_ptrkeeps its heap address, and a borrowed span points outside the table entirely.
-
std::unique_ptr<app_field_slot_t[]> owned_slots = {}¶
The owning install’s slot array; null for a borrowed install. A
unique_ptr<T[]>rather than avectorso the table stays the same size as whenslotswas the vector (pointer + span == vector on both host and rv32) and drops the vector’s capacity word. Never resized: a re-install builds a whole new table and move-assigns it under the stripe lock.
-
std::vector<std::byte> backing = {}¶
Owned copy of the declaration bytes for the owning install (name then descriptor, concatenated per field); empty for a borrowed install whose slots view caller storage.
-
std::span<const app_field_slot_t> slots = {}¶
-
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 aT[N]or astd::array— the two spellings aconstexpr/statictable takes — and NOT from astd::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-scopeT[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::arrayof 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
.bssslot 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.
-
constexpr borrowed_fields_t() noexcept = default¶
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.
awaitwaits 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
awaitwaiters 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. Seevertex_t::storefor the ordering argument that makes the lock-free read safe against a lost wakeup.
-
std::mutex m¶
-
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:
constinitwhere the platform’sstd::mutexis 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) becausestd::condition_variablecan never be constant-initialized — only the cold await/wake paths reach it.The stripe at table slot
idx(guarded-static fallback: this platform’sstd::mutexhas no constexpr ctor, so the table cannot beconstinit).
See: path, views, status & errors, security & ACL, config, interface-map.