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

/net

The net root the constructor registers, and the position every module hangs under; :children[] on it enumerates the modules. Conventionally /net; the name is the constructor’s default, overridable per node, never a library rule.

role_t::STORED_VALUE

none (a structural position)

/net/<module>

One module — a (transport kind, role) pair, declared by the application (register_module, core/include/libtracer/transport_vertex.hpp:transport_vertex_t::register_module). Minted eagerly when the module is declared (core/src/transport_vertex.cpp:transport_vertex_t::mint_module_locked) and lazily on first creation (core/src/transport_vertex.cpp:structural vertex, created lazily on first use).

role_t::STORED_VALUE

none (a structural position)

/net/<module>/conn

The module’s creator endpoint. A write is executed, never assigned: the payload’s TLV type selects create (SPEC) from remove (NAME) — core/src/transport_vertex.cpp:transport_vertex_t::endpoint_write.

role_t::HANDLER

none — write-only and valueless

/net/<module>/<name>

The connection vertex: one link’s identity, its config, and (when config-constructed) the socket it owns.

role_t::STORED_VALUE

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, backoff and connect_timeout are creation-time config carried in the SPEC, parsed into the transport-private conn_settings_t. They are not a vertex :settings surface — that core namespace was emptied outright by RFC-0022 §3.B — and they are not / children either.

  • A per-connection :stats facet. ADR-0027’s worked example showed :stats on 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 :identity mould — 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_child cannot grow, the creation rolls back — retire the vertex, drop the entry, destroy the socket — and answers BACKPRESSURE (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 no dst resolves and no removal can take down.

  • SPEC naming an existing name answers PATH_IN_USE, and the reserved conn name 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 — SPEC demands CREATE(0x08), NAME demands WRITE(0x02) — so the one write gate demands a different right per payload type, and a peer may hold either without the other (S2c, pinned by core/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.


The lean rule: no kind-specific fields on the shared record

Standing requirement. tr::net::conn_settings_t carries only the keys every transport kind means the same thing by. A kind’s private configuration never lands there — the kind’s own factory parses it from the raw config SETTINGS TLV it receives alongside the parsed universal settings (core/include/libtracer/transport_factory.hpp:conn_settings_t, ADR-0043 §3, §5).

The mechanism is the factory signature: a factory is (const conn_settings_t&, const wire::tlv_node_t* raw_config) -> result_t<unique_ptr<transport_t>>, registered at runtime through transport_vertex_t::register_transport_type (core/src/transport_vertex.cpp:transport_vertex_t::register_transport_type(std::string_view kind, transport_factory_t factory,). The central parse reads the universal keys and nothing else (core/src/transport_vertex.cpp:parse_config, core/src/transport_vertex.cpp:if (const auto v = cfg.name("kind"))); unknown pairs are ignored, so a newer peer may send keys this node has never heard of. quic reads its own tls / insecure, ws and tcp read peer_named / max_peers, can reads its bus identity — and none of them can see another’s vocabulary (connection-config).

A kind-private key never names a file. A creation SPEC can come from any writer, a remote one included, so the TLS kinds take their certificate, key and CA bundle from the application: it registers a table of named tls_profile_t with the factory, and tls can only select one of those by name. An unregistered name is refused with TYPE_MISMATCH before any file is opened.

Why the rule exists

  • Optionality. QUIC is a separate link target that the core never references; a device without the module contains zero QUIC schema (ADR-0043 §1, §5). A tls field on the shared record would put that vocabulary — and its bytes — on every node, including the 16 KB class that cannot carry TLS at all.

  • Open/closed. transport_vertex.cpp never learns what msquic is. Adding a transport kind is linking a target and calling one registration function; it is not an edit to the file that composes paths. That is what makes an out-of-tree kind — an embedder’s own — a first-class participant rather than a fork.

  • A shared key that only one kind reads is a dead key. The failure mode is already visible inside the universal set: keepalive_ms was parsed and no consumer anywhere in the tree read it, until #1666 deleted the field and left the key accepted-and-ignored (backoff_ms / connect_timeout_ms escaped that condition when the S5 liveness engine landed — core/include/libtracer/transport_factory.hpp:conn_settings_t::backoff_ms, core/include/libtracer/transport_factory.hpp:conn_settings_t::connect_timeout_ms; 13). One record carrying N kinds’ private vocabulary would be that condition by construction rather than by accident — and a mistyped or misplaced key is silently ignored, so nothing would report it.

  • Layering. L4 never learns what a client or a listener is: the graph owns the addressing, and this tr::net seam owns the catalog entry (the file header of core/include/libtracer/transport_vertex.hpp). A kind-private field on the shared record drags one kind’s vocabulary up into the layer that composes every path.

What the rule is not

It is not “the record never grows”. max_frame was added to the universal set later, as a per-connection receive cap that every framed kind honours — the length-prefix streams read it off their u32 prefix, ws off the RFC 6455 frame header, 0 meaning the protocol default, and it only ever tightens. That is the test a candidate key must pass: every kind reads it, and every kind means the same thing by it. A key that fails the test belongs in the kind’s factory, however convenient the shared record looks.

The same discipline, one layer down (store composition)

Where a kind needs something the plane owns, the plane exposes a seam, not a field. transport_vertex_t::egress_source hands an out-of-tree factory the very egress store the built-in factories wire into their sockets, so a quic or can link is bounded by the same budget rather than silently falling back to the process heap. How many such stores a node wires is its composition, and it has three named points (ADR-0079, Amendment 2026-08-20): folded — everything on one store, the single-threaded-MCU recipe; per-plane — one store per plane, which fences a net-plane flood off from the graph and is worth choosing for exactly that (it is measured to collapse under fan-out just as folded does); per-thread — this accessor ignored in favour of a per-RX-thread or per-connection source, the only point that survives a fan-out and the multi-RX-host recipe, paid for in per-store slack (≈14 KB on an ESP32-C6-class target, an estimate ADR-0079 marks as owing a measured high-water-mark census). None of the three is a default — every seam defaults to the process heap, so a node that says nothing is all-heap. The point for this page: the knob is an injected store reached through an accessor, not a store field on conn_settings_t — the same rule, applied to a resource instead of a config key.


Realisation status

Implemented. Connection vertices at /net/<module>/<name>, mounted and routed at that same key; the per-module creator endpoint and its SPEC/NAME dispatch (RFC-0014 S2b), including wire-driven removal; the liveness value as the vertex value, await-able and subscribable; application-declared modules with no library-derived fallback (ADR-0073 §4); the structural-vertex predicate; the lean factory signature and the runtime kind catalog; hiding conn from /net/<module>:children[] (S4 — the endpoint is not a member connection, but stays addressable so the §6 creatability probe still reaches it); and the S5 liveness engine (self_heal_link_t) for kinds registered self_heal_dial, with the standing-binding refcount seam (acquire_link/release_link) — and the built-in point-to-point kinds’ opt-in to it (#1548): udp, tcp and ws DIAL connections are engine-managed on a build that carries the engine (opt-in since v0.17.0, #1670), LISTEN links and bus kinds unchanged. And S7: the superseded :children[] creation spelling is retired — the client and listener child types are no longer registered and the role config key is no longer read, so the creator endpoint is the sole door and the role is positional in fact, not only on paper.

Implemented too: the CREATE/WRITE gating split (S2c, RFC-0014 Amendment 2), the catalog envelope (S3, Amendment 3 — an empty SETTINGS for a module that declares none), and the S3 module-side half: a module-declared conn:schema catalog, served in that envelope and validated against at creation (#1815). RFC-0014’s byte-level clauses — the liveness encoding among them — are normative since Amendment 4, with the values in core/include/libtracer/transport_factory.hpp:link_state_t as the reference encoding.

Implemented since #1816: the routing-plane caller of the refcount seam. On a build with the engine, every remote subscription edge that delivers over a connection calls acquire_link when it is admitted and release_link when it is cleared, replaced or evicted, through the graph’s link_hold seam. The engine’s count is the only state, so a subscription over a dormant link dials it and keeps it self-healing, and its teardown is the last standing release. An await takes no standing hold.

Pitfalls

  • Reading listening as “a peer is attached”. A LISTEN vertex’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 :settings edit that moves a peer’s address, and a re-SPEC answers PATH_IN_USE.

  • Granting WRITE on the endpoint and expecting it to create. Since the CREATE/WRITE split (S2c), WRITE admits only removal; creating needs CREATE on 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’s conn:schema catalog, declared at register_module (§7), which advertises it and validates it without touching the shared record.

  • Expecting a per-transport statistics FACET. A connection vertex has no :stats of 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 is tr::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 a SPEC for 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.