Reference 19 — Transports are vertices¶
In one paragraph
A transport is not a side channel bolted onto the graph — it is a vertex in it. Every
connection a node holds is an ordinary / vertex at /net/<module>/<name>: created by an
in-band write, addressed by path, admitted by the same ACL gate, await-able and
subscribable on its liveness value, and retired by the same retire as any other vertex
(ADR-0027 — A transport, and each connection within it, is a first-class / vertex).
That is what lets a third party form a whole network with nothing but vertex writes
(13 — Network formation) and lets a caller address a peer’s
sensor without opening a socket or naming a URI (07 — Host embedding).
It is not free, and the costs are stated below: a mount run spends path bytes at every hop,
the vertex and the link it names are two lifecycles that can disagree, the plane mints
structural vertices nobody declared, and creation runs a socket constructor inside a
control-plane critical section on a receive thread. The rule that keeps the price bounded is
leanness: the shared tr::net::conn_settings_t carries only the keys every transport
kind means the same thing by, and a kind’s private config is parsed by that kind’s own
factory out of the raw config TLV
(ADR-0043 §3, §5).
This page is the canonical statement of the commitment itself — what is a vertex, what it
buys, what it costs, and the standing rule that bounds the cost. It is not the formation
flow (that is 13), not the per-hop routing story (that is
CONTEXT.md §Path-as-route with 07), not the CAN
specifics (14), and not the key-by-key config vocabulary (that is the
connection-config module page). The reference
implementation’s seam is tr::net::transport_vertex_t
(core/include/libtracer/transport_vertex.hpp, core/src/transport_vertex.cpp); the
original ticket is #83.
What is a vertex, and what is not¶
The net plane registers four kinds of thing in the path tree. They differ in role, and the differences are load-bearing:
Position |
What it is |
Role |
Value |
|---|---|---|---|
|
The net root the constructor registers, and the position every module hangs under; |
|
none (a structural position) |
|
One module — a (transport kind, role) pair, declared by the application ( |
|
none (a structural position) |
|
The module’s creator endpoint. A write is executed, never assigned: the payload’s TLV type selects create ( |
|
none — write-only and valueless |
|
The connection vertex: one link’s identity, its config, and (when config-constructed) the socket it owns. |
|
the 1-byte link-liveness state |
Three things are deliberately not vertices:
A peer of a bus link. A bus transport’s audible peers are synthesized on every read of the connection vertex’s
:children[]from the transport’s own live-traffic table; no vertex is ever created for a peer, so a node stays O(its own links) rather than O(the network) (ADR-0044; 07 §node identity).A connection’s config.
addr,port,kind,max_frame,backoffandconnect_timeoutare creation-time config carried in theSPEC, parsed into the transport-privateconn_settings_t. They are not a vertex:settingssurface — that core namespace was emptied outright by RFC-0022 §3.B — and they are not/children either.A per-connection
:statsfacet. ADR-0027’s worked example showed:statson the connection vertex — a facet whose content is the addressed vertex’s. That was never implemented and is not what shipped. What DID ship (RFC-0010 Amendments 1 and 2) is the opposite shape: a node-scoped:stats.<class>.<name>census that takes no vertex and answers identically on every one, in the:identitymould — so a link’s counters are read as<any-vertex>:stats.link.<child>, keyed by the child NAME, and never as a facet of the connection vertex itself (ADR-0027 erratum 2026-07-30, #583; the census is #1503).
The dividing rule is ADR-0027’s, unchanged: distinct lifecycle and identity ⇒ a /
vertex; scalar config of a thing ⇒ config on that thing. A connection is created and
destroyed, has up/down state to observe, and has its own ACL, so it is an identity. A port
number is not. Only the spelling of the second half has moved: ADR-0027 wrote it
:settings, and RFC-0022 §3.B later emptied that namespace, so a connection’s scalars now
live on the transport-private record reached through the kind’s own config door
(ADR-0021
§Decision 3, device-private facets).
What “is a vertex” buys¶
Uniform addressing¶
A connection vertex mounts its peer’s graph under itself: its :-facets are the link’s
own control surface, and its /-subtree is the peer’s tree, reached by forwarding the
unresolved suffix (CONTEXT.md §Path-as-route). So /net/can/can0/wheel/left
is one address that a caller reads with the same call it uses on /sensor/temp — no socket
handle, no URI, no destination field, and no second naming layer
(07 §per-host view).
This is not merely a spelling convenience: it is why the mount path and the routing key are
the same string. Creation composes net/<module>/<name> once and registers it both as the
graph key and as the router’s child name — “the routing key IS the mount path”
(core/src/transport_vertex.cpp:The routing key IS the mount path (ADR-0061), core/src/transport_vertex.cpp:transport_vertex_t::make_connection_locked,
ADR-0061) —
so a hop’s src prefix needs no per-hop assembly, and a route through a link cannot name a
vertex the graph does not have. A design that kept transports outside the tree would have to
keep those two namespaces in agreement by hand.
Uniform lifecycle¶
Creation and removal are ordinary writes, gated by the ordinary write path, and they collapse
onto one control distinguished by the written TLV’s type
(core/src/transport_vertex.cpp:transport_vertex_t::endpoint_write). Removal un-routes first, then retires the identity
vertex, then destroys the socket — so a forward can never reach freed memory, and the path
re-virginizes for a later connection of the same name
(RFC-0009 §B.6).
The uniformity extends inward, not only outward. There is exactly one creation door —
the
RFC-0014 — Creator endpoint: connection lifecycle and link liveness
creator endpoint at /net/<module>/conn. There were two until
#492 S7 retired the superseded
:children[] spelling: the net plane no longer registers the client and listener child
types, so a :children[] creation SPEC naming either answers SCHEMA_NOT_FOUND. Two doors
sharing one body could not drift into two creation semantics; one door cannot drift at all.
Module resolution is now purely positional — the endpoint knows its module from its own path,
and the SPEC’s kind, when present, only cross-checks that module’s declaration rather than
selecting one.
flowchart TD
W["write /net/ws-server/conn = SPEC{name, config}"] --> G["graph_t: WRITE gate admits, then on_write"]
G --> D{"payload TLV type"}
D -->|"SPEC"| C["resolve the module's declaration: it fixes kind and role"]
D -->|"NAME"| R["remove: un-route, retire the vertex, close the socket"]
D -->|"anything else"| E["ERROR type_mismatch — never an ordinary assign"]
C --> S{"a link staged for this module/name?"}
S -->|"yes"| L["use the staged transport (borrowed; the caller owns it)"]
S -->|"no"| F["the kind's factory constructs the socket (the vertex owns it)"]
L --> V["register the identity vertex at /net/module/name"]
F --> V
V --> RT["router add_child: the NAME-to-link demux entry"]
RT --> LS["publish liveness: UP for DIAL, LISTENING for LISTEN"]
Uniform introspection¶
The connection vertex’s value is its liveness state — a 1-byte link_state_t
(core/include/libtracer/transport_factory.hpp:link_state_t) emitted as an ordinary VALUE TLV
(core/src/transport_vertex.cpp:link_state_value). Because it is a vertex value and not a side-channel
callback, all three primitives already work on it: read it, await it, or subscribe to
/net/<module>/<name> and receive every transition without polling (assign-then-deliver,
RFC-0008 §D).
A monitoring peer needs no transport-specific verb and no per-transport statistics facet —
which is why the absence of a per-connection :stats facet costs less than it looks.
The same holds for enumeration: /net:children[] lists the modules, /net/<module>:children[]
lists that module’s connections, and a bus connection’s :children[] lists the peers audible
right now. One walk sees a node’s whole wiring.
What it costs¶
1. Every hop spends path bytes¶
A mount run is three segments (net/<module>/<name>, plus a fourth for a bus peer), and a
forwarding hop consumes the whole run rather than one segment (RFC-0014 S2a, ADR-0061). The
segment cap is 255, but the 1024-byte PATH budget binds first: at 20 B/hop for
/net/can/c0 and 32 B/hop for /net/ws-client/board-01, the reachable diameter is roughly
30–50 hops (13 §connection direction and folding, arithmetic
over RFC-0023
§4.3 rather than a routed measurement). A transport kept outside the path tree would spend
none of that budget. Putting it in the tree is what makes the route explicit and loop-free by
construction, and the bytes are what that costs.
2. Two lifecycles under one identity¶
The vertex is explicit — created solely by a SPEC write or an owner-local registration,
removed solely by a NAME write or an owner-local retire. The link underneath is a state
machine that is supposed to run itself (RFC-0014 §4). They can disagree, and the disagreement
is visible on the wire: a vertex can exist while its link is dormant, and a LISTEN vertex
reporting listening says its listen socket is bound — not that any peer is attached.
The liveness engine runs for opted-in kinds. The automatic dial / backoff / reconnect
transitions RFC-0014 §4 describes are implemented by the S5 engine
(tr::net::self_heal_link_t, core/include/libtracer/self_heal_link.hpp): a kind
registered with transport_kind_traits_t::self_heal_dial gets DORMANT creation (no
socket), demand dial bounded by connect_timeout, self-heal with backoff while a
standing binding (acquire_link/release_link) holds the link, and close-to-dormant on
the last release. The BUILT-IN point-to-point kinds udp, tcp and ws are opted in
(#1548) — on a build that carries the
engine (kSelfHealLinks = true, opt-in since v0.17.0,
#1670) their DIAL connections are
engine-managed, so creation does not fail when the peer is down. Everywhere
else the value is still written by whoever knows: an eagerly-constructed socket (every LISTEN
link, every bus kind, a stock kSelfHealLinks = false build) publishes UP or LISTENING at
creation (core/src/transport_vertex.cpp:if (constructed), core/src/transport_vertex.cpp:effective_role == conn_role_t::LISTEN), and a provided link reports through
set_link_state.
3. Structural vertices nobody declared¶
Making the plane addressable means minting positions that hold no application datum: the net
root and each /net/<module> segment. They are registered with role_t::STORED_VALUE and
carry no field descriptor table, so an embedder walking graph_t::for_each_vertex sees them
as value vertices whose owner forgot to describe them — byte-identical :schema shape,
differing only in the NAME. Only the object that minted them can tell:
transport_vertex_t::is_structural(key), and the graph deliberately answers no such question
in general, because an application’s own position-holder (a /zone with nothing but
children) is indistinguishable from a connection vertex on every graph-visible surface
(11 — Vertex roles and aggregation §structural
vertices, #1096). Even that
predicate answers by name match, not provenance: a vertex an application registered at
/net/<module> first answers true.
4. Control-plane work runs on a data thread¶
Because creation is a write, it arrives on whichever transport’s receive thread delivered the
frame, and the graph invokes it outside its own map lock. So the class serializes every
control-plane mutation on its own mutex, and that critical section constructs sockets —
it can block for milliseconds, which is why it is a plain mutex rather than an
interrupt-disable section or a spinlock
(ADR-0063
§3 and its erratum 1). The declared lock order is transport_vertex_t → fwd_router_t →
graph_t → the vertex stripe, and nothing on the forward or delivery path takes any of
them. A peer’s write to a creator endpoint can therefore dial a socket; a peer’s data
write never waits behind one.
The order is the weaker half of the discipline. It says which lock may nest inside which,
and nothing about a call that leaves the class and comes back round the outside — which two
of this class’s own calls do. A liveness publish fans out to the connection’s subscribers,
and RFC-0014 §4’s standing-binding seam is driven by a routing plane watching exactly that
liveness, so the subscriber calls acquire_link straight back in. A teardown joins the
liveness engine’s worker, which is the thread doing the publishing. So the binding rule is
the second one: transport_vertex_t never holds its control mutex across a call that can
re-enter it — a subscriber fan-out or a thread join. Those run in phase 2 of a
ctl_txn_t, an RAII transaction that decides under the lock and discharges with it
released; it is the class’s only acquisition of that mutex, so there is no hand-written
unlock path to forget
(ADR-0063
erratum 7). What stays inside the hold — the graph lookups and registrations, the router’s
add_child — are structural mutations that dispatch nothing and join nothing.
5. Creation is a peer-drivable resource¶
A door on the wire is a door an unfriendly peer can knock on. Two mitigations are in the tree, and one is not:
The registry’s refusal is the whole creation’s verdict: when
add_childcannot grow, the creation rolls back — retire the vertex, drop the entry, destroy the socket — and answersBACKPRESSURE(core/src/transport_vertex.cpp:(void)pending_links_.erase(staged_key);,core/src/transport_vertex.cpp:if (!router_.add_child(qv.data(), *link, nullptr,). Without that, a bounded node could be driven to publish connections that nodstresolves and no removal can take down.SPECnaming an existing name answersPATH_IN_USE, and the reservedconnname is refused in both directions, so the endpoint cannot be made to destroy itself.Create and remove are two rights, not one: the endpoint declares RFC-0014 §5’s mapping through the Amendment 2 payload-right table —
SPECdemandsCREATE(0x08),NAMEdemandsWRITE(0x02) — so the one write gate demands a different right per payload type, and a peer may hold either without the other (S2c, pinned bycore/tests/payload_right_table_test.cpp).
6. No reconfiguration door¶
The only accessor for a connection’s parsed settings, transport_vertex_t::settings_of,
hands out a const view, and there is no post-creation config write: a re-SPEC is
PATH_IN_USE, never a reconfiguration. A peer whose IP changed is therefore retired
(NAME) and re-created (SPEC), which un-routes the link and cascade-evicts the
subscriptions routed through it. That is the cost of “the vertex is the identity, the config
is creation-time”: there is no in-place edit of a thing whose whole existence is defined at
creation.
7. The config vocabulary is self-describing only where a module declares it¶
Uniform introspection reaches the creation catalog too, but only as far as a module opts in.
RFC-0014 Amendment 3 pinned the catalog’s envelope (the endpoint’s ordinary :schema record),
and since #1815 a module can declare a
catalog into it: register_module takes a borrowed static constexpr table of
tr::net::conn_key_t, and read <module>/conn:schema then answers
POINT{NAME "conn", SETTINGS{…}} with one RFC-0013 §B per-key record per key
(NAME <key> SETTINGS{NAME "dtype" NAME <tag>, [NAME "required" VALUE 01], …}). The same
table is what the endpoint validates a SPEC against: a missing required key, or a catalogued
key in another type or width, is refused tr::schema::type_mismatch at the write (§2), before
any factory runs. Kind-private keys are described there, on the module’s registration, and
never on the shared record — the lean rule below holds.
The cost is the opt-in. A module that declares nothing still answers the generic whole-vertex
:schema (an EMPTY SETTINGS, Amendment 3’s conforming no-catalog answer) and validates
nothing, and the endpoint is hidden from the module’s :children[] (S4), so that probe is the
only way to learn anything about it. Every module name is application-declared, so the library
cannot declare a catalog on the application’s behalf; until a module does, a creator learns its
kind’s config keys out of band, from the
connection-config module page.
Pitfalls¶
Reading
listeningas “a peer is attached”. ALISTENvertex’s liveness reports listen-socket reachability only. Accepted peers are the connection vertex’s synthesized:children[], and a server with zero peers is healthy.Treating the connection vertex as a config record. Its value is liveness; its config is creation-time and const thereafter. There is no
:settingsedit that moves a peer’s address, and a re-SPECanswersPATH_IN_USE.Granting
WRITEon the endpoint and expecting it to create. Since theCREATE/WRITEsplit (S2c),WRITEadmits only removal; creating needsCREATEon the endpoint.Inferring structural-ness from path shape. “Two segments under the root” claims every application
/zone/<child>as well. Ask the object that minted the vertex.Adding a kind’s key to
conn_settings_t“just for now”. The record is shared by every kind and by every node that links the core; a key only one factory reads is a key the other kinds carry. The place to describe a kind-private key is the module’sconn:schemacatalog, declared atregister_module(§7), which advertises it and validates it without touching the shared record.Expecting a per-transport statistics FACET. A connection vertex has no
:statsof its own. The counters are reached through the node-scoped census instead —<any-vertex>:stats.link.<child>— and only on a node that has a router to sample them; anything else istr::schema::not_found.
Boundaries¶
The peer’s tree below a mount is not this page. Everything about how the suffix is forwarded, stripped and replied to is CONTEXT.md §Path-as-route, 07 and 13.
“Transport” here means a connection, not a wire technology. What a given kind does with the bytes — CAN’s header elision and advertise map, WebSocket’s session authentication — is 14 and 16.
A staged link is not yet a vertex.
provide_link(core/src/transport_vertex.cpp:transport_vertex_t::provide_link) hands the plane a pre-built transport — the test/manual seam for loopback channels and kinds the catalog does not cover — but it registers nothing. The vertex appears when aSPECfor that<module>/<name>binds it, and removing that connection leaves the borrowed link alone.This is not an API page. The reference implementation’s signatures are its own headers; what is normative about creation, removal and liveness is RFC-0014 and the spec, and what is descriptive-but-canonical is stated here.