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 entire data API is three calls — read / write / await — and 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). Reads/writes of the last-known-value are lock-free.

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 (a bounded ring sized by :settings.history_keep_last), or handler (your 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 never take the per-vertex mutex; that mutex guards only the subscriber list, history, and the await waiter accounting; a per-vertex condvar makes await block until the next write ([ADR-0015] in the repo).

Subscriptions are field-writes, not a verb ([ADR-0006]): subscribing is writing a SUBSCRIBER TLV into :subscribers[]. On each write the dispatcher clones the value to every subscriber’s target vertex and/or in-process callback, with a dispatch-depth cap (32) that terminates in-process cycles. :schema reads return a POINT descriptor.

Interface

enum class role_t { STORED_VALUE, STREAM, HANDLER };
struct settings_t { /* reliability, durability, history_keep_last, deadline_ns, … */ };
struct handlers_t { std::function<result_t<rope_t>()> on_read;
                  std::function<result_t<void>(const rope_t&)> on_write; };

class graph_t {
    vertex_handle_t register_vertex(const path_t&, role_t, handlers_t={}, settings_t={}); // infallible (ADR-0056)
    result_t<vertex_handle_t> try_register_vertex(const path_t&, role_t, handlers_t={}, settings_t={}); // runtime path
    result_t<rope_t>    read (vertex_handle_t) const;  // atomic LKV load (rope; scalar = 1 link)
    result_t<void>    write(vertex_handle_t, rope_t);  // store + fan-out (view_t → rope_t implicit)
    result_t<rope_t>    await(vertex_handle_t, std::chrono::nanoseconds);   // blocks for next write
    result_t<std::vector<rope_t>> history(vertex_handle_t) const;    // stream window

    result_t<void> subscribe(const path_t& src, const path_t& target);    // re-dispatch
    result_t<void> subscribe(const path_t& src, std::function<void(const rope_t&)>);  // callback

    result_t<void> write(vertex_handle_t, const field_path_t&, rope_t);  // handle-based field-write
    std::optional<vertex_handle_t> find(std::span<const std::byte> key) const;  // resolve → handle
    result_t<rope_t> read (const path_t&) const;       // field tail → :schema, …
    result_t<void> write(const path_t&, rope_t);       // field tail → :subscribers[]/:settings.*
};

No strings on the hot path

The hot path is handle-typed (the spec’s rule, reference/10 §path-handle). A path_t encodes the canonical PATH bytes once — the path_t(std::string_view) constructor for a known-good literal (ADR-0054), 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.

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

Write → 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 (lock-free)
    G->>V: snapshot subscribers (brief lock)
    G-->>S1: callback(clone)  %% refcount bump
    G-->>S2: write(target, clone)  %% re-dispatch, depth-capped
    Note over V: await waiters woken via condvar
    G-->>P: OK
    

Benefits

  • Three primitivesread/write/await plus field-writes cover pub/sub, QoS, and discovery; no connect/subscribe API to mismatch.

  • Lock-free reads — the LKV is an atomic pointer swap; readers never block on writers (validated race-free under TSan).

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

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

API reference

The declarations below are pulled directly from the reference implementation’s headers (core/include/libtracer/graph.hpp, vertex.hpp) by Doxygen — they cannot drift from the code.

class graph_t

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

Vertices are keyed on their canonical PATH-TLV payload bytes (docs/reference/02 §dispatch). 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

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

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

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

Public Functions

graph_t()

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

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

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

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

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

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

Returns:

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

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

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

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

Returns:

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

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

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

A :children[] SPEC write whose type is unregistered returns SCHEMA_NOT_FOUND (the ENOTTY of an unsupported creation). Not thread-safe against concurrent creation — call at setup, before frames flow (mirrors the delivery sink). The built-in stored_value type is registered by the constructor.

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

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

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

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

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

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

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

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

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

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

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

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

void propagate(vertex_handle_t v)

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

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

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.

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

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

Returns:

The stored value as a rope, or a status_t (e.g. TIMEOUT).

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

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

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

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

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

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

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

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

result_t<void> subscribe(const path_t &src, const path_t &target)

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

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

result_t<void> subscribe(const path_t &src, std::function<void(const rope_t&)> callback)

Subscribe src to an in-process callback (sugar; fires inline on each delivery to src with a cloned view).

Delivery is value-agnostic (RFC-0008): WHICH vertices a sweep propagates is the source vertex’s delivery_mode, not a per-edge policy. A std::function 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.

void set_remote_delivery_sink(std::function<void(const remote_delivery_t&, const rope_t&)> sink)

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

Set once at wiring time by the transport plane (tr::net::fwd_router_t) before frames flow; the sink fires on whatever thread calls write (outside the vertex lock), and on subscribe for a transient-local latch. L4 keeps it as an opaque std::function, so the graph never depends on a transport. A null sink (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.

void set_subject_resolver(subject_resolver_t resolver)

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 maps its caller context through it and — when a subject token comes back — evaluates the target vertex’s effective ACL (own ACEs + ancestor ACEs carrying INHERIT, ADR-0020): allowed iff some non-expired ACE with a matching subject (or "EVERYONE@") grants the operation’s right bit; a vertex whose effective ACL is empty stays open (enforcement is opt-in per vertex via ACL presence). Denial returns status_t::PERMISSION_DENIED (tr::access::denied on the wire, RFC-0002).

Set once at wiring time, before frames flow — read-only afterwards on the op paths, so no lock (the remote-sink / child-catalog contract).

result_t<void> subscribe_wire(vertex_handle_t v, view_t source_view, view_t return_route, std::string link)

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

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

result_t<rope_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.deadline_ns, :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<rope_t> await(const path_t &path, std::chrono::nanoseconds timeout)

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

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

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

const settings_t &settings(vertex_handle_t v) const noexcept

The QoS settings of the vertex v names (ADR-0056).

The read accessor the opaque handle does not expose directly: a resolver that needs a per-vertex knob (e.g. store_ref_min_bytes, ADR-0042 §3) queries it here instead of dereferencing the vertex. Wiring-stable — settings change only via :settings writes.

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

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

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

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.

class vertex_t

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

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

Public Functions

inline vertex_t(role_t role, path_key_t key, settings_t settings, handlers_t handlers)

Construct a vertex with its role, canonical key, QoS settings, and handlers.

inline role_t role() const noexcept

This vertex’s behavioral role.

inline const path_key_t &key() const noexcept

This vertex’s canonical PATH-payload key (the vertex-map key).

inline const settings_t &settings() const noexcept

This vertex’s QoS settings.

enum class tr::graph::role_t

A vertex’s behavioral role (docs/reference/11 §roles).

Values:

enumerator STORED_VALUE

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

enumerator STREAM

Role 2: bounded history ring sized by settings.history_keep_last.

enumerator HANDLER

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

See: path, views, interface-map.