connection config — the SPEC config keys (L4)¶
In one paragraph
Creating a connection is an ordinary write — write /net/<module>/conn = SPEC{name, config} at the RFC-0014 creator endpoint, the ONE door since S7 retired the
/net:children[] creation spelling — and everything the new link needs is in that
config SETTINGS TLV.
Its keys come in two families. The universal ones (kind, addr, port,
max_frame, …) are parsed centrally into tr::net::conn_settings_t, which every
transport kind shares. The kind-private ones are parsed by the selected kind’s
own factory, module-side, and never land on that shared record — so quic reads
tls/insecure, ws and tcp read peer_named/max_peers, can reads its bus
identity and ingress bounds, and none of them can see each other’s vocabulary. This
page is the key-by-key reference for both families. Two facts are worth reading
before you ship: no key ever carries a file path — quic/webtransport TLS material
is app-owned and a SPEC can only name one of the app’s profiles — and a SPEC-created
quic/webtransport dialer verifies its peer’s certificate by default.
What this page is, and what it is not¶
This is the reference home for the connection-creation config keys. Before it
existed, a key was discoverable only from the factory’s own Doxygen block or from
core/CHANGELOG.md — including the two quic keys that decide whether a dialer
authenticates its peer at all.
It is not the creation protocol: the SPEC shape, the ACL gate on the write, the
/net/<module>/<name> mount and the liveness value all belong to
fwd-router § the /net connection model. It is not the walk
either — the positional pair grammar and its forward-compat rules are
config § runtime configuration. This page is the vocabulary: which
keys exist, which factory reads each one, what wire value each takes, and what it
does.
Its subject is deliberately bounded to what the transport-side readers read. The
creation SPEC’s own envelope (name, config) and the SUBSCRIBER QoS
SETTINGS read the same positional grammar — since
#985 through the same
tr::wire::config_reader_t type — but they are not connection config, and the ACL
SETTINGS walk keeps its own reject-unknown-keys code
(#906).
The shape of a config¶
A config value is a SETTINGS TLV whose children are positional
(NAME key, value) pairs. String-valued keys take a NAME child; integer and
boolean keys take a VALUE child holding a little-endian unsigned integer. Four
rules from the shared reader apply to every key on this page:
Unknown pairs are ignored, whole — key and value — so a newer peer may send keys this node has never heard of.
A wrong-typed value is ignored as though the key were absent; so is a
VALUEpayload whose size is not exactly the width this table gives (an empty payload being that rule’s trivial case). A u16portsent as four bytes reads as absent rather than silently dropping its high bytes, and a u32keepalivesent as two reads as absent rather than zero-extending (#928).A repeated key resolves to its last well-formed occurrence.
A child that is not a
NAMEwhere a key belongs stops the walk. Every key after that point reads as absent.
The consequence that bites: there is no error return for a misspelled or
mistyped key. insecrue = 1 and insecure = "1" (a NAME value where a VALUE
belongs) both create a connection that silently took the default. The gate against
that is reading the table below, not a status code — unless the module declares a
catalog.
A module may declare its conn:schema catalog when the application registers it
(register_module’s optional tr::net::conn_catalog_t, RFC-0014 §2 / Amendment 3,
#1815): a static constexpr
table of tr::net::conn_key_t, each naming a key, its value shape (utf8, bool,
u8, u16, u32) and whether it is required. Against such a module, a SPEC that
omits a required key, or carries a catalogued key in any other type or width, is
refused TYPE_MISMATCH before a factory runs, and read /net/<module>/conn:schema
advertises the same table. The catalog does not catch a misspelled key: a key it
does not name is still an unknown pair and is ignored. With no catalog declared,
the rules above are the whole story.
Writing one — tr::net::conn_spec_t¶
The library ships the encoder as well as the reader (libtracer/conn_spec.hpp).
conn_spec_t is a fluent builder over the shape above: each setter appends one
(NAME key, value) pair and returns *this, and the terminal bytes() / view()
wrap the pairs in the config SETTINGS and the whole thing in the SPEC.
using tr::net::conn_role_t;
using tr::net::conn_spec_t;
// A module must exist before anything can be created in it: it mints the endpoint and
// fixes the (kind, role) pair positionally.
(void)net.register_module("ws-client", "ws", conn_role_t::DIAL);
(void)net.register_module("ws-server", "ws", conn_role_t::LISTEN);
// The one-call form — the 90% case.
graph.write(*path_t::parse("/net/ws-client/conn"),
tr::net::conn_spec("up", 8080, "ws", "127.0.0.1"));
// The builder, for the rest: universal keys by name, kind-private keys as pairs.
// The module in the PATH fixes both the transport and the role, so the SPEC carries
// neither a `type` nor a `role`.
graph.write(*path_t::parse("/net/ws-server/conn"), conn_spec_t("srv")
.port(8080)
.max_frame(4096)
.flag("peer_named", true) // ws-private
.u32("max_peers", 8) // ws-private
.view());
// Removal is the other half of that one control: a bare NAME, told apart by TLV type.
graph.write(*path_t::parse("/net/ws-server/conn"), tr::net::conn_remove("srv"));
The named setters are exactly the universal keys of the next section; the generic
text / u8 / u16 / u32 / flag pairs carry a kind’s private vocabulary,
which the builder deliberately does not know (that coupling is what
ADR-0043
§5 forbids). Their names mirror config_reader_t’s accessors, so the encode and
decode vocabularies cannot drift apart.
Two properties are worth knowing before you build one:
A builder on which no setter ran emits no
configat all — that is theprovide_link-staged spelling, where the module comes from the staging rather than from akind.There is no
modulekey, and the builder invents none. The endpoint a SPEC is written to is the module; akindpair, when present, must be one that module declares. See thekindrow below.
The builder does not validate a key against a kind — it cannot, for the reason above — so the misspelling hazard in the paragraph before this section is unchanged. What it removes is the other failure mode: a private near-copy of the encoder per call site, each free to drift.
Universal keys — parsed into conn_settings_t¶
Read once, centrally, for every kind (core/src/transport_vertex.cpp). A kind’s
factory receives the parsed record alongside the raw config TLV.
key |
value |
applies to |
default |
meaning |
|---|---|---|---|---|
|
|
both |
empty |
Selects the transport factory ( |
|
|
DIAL |
empty |
Peer address, IPv4 dotted-quad. A DIAL with it empty answers |
|
|
both |
absent |
Peer port on a DIAL, bind port on a LISTEN. On a DIAL, |
|
|
both |
|
Per-connection inbound frame cap in bytes, honoured by five of the six kinds — |
|
|
DIAL |
|
Self-heal retry interval in ms (RFC-0014 §4), consumed by the S5 liveness engine ( |
|
|
DIAL |
|
How long one dial attempt waits for |
keepalive — accepted, ignored¶
A keepalive pair (VALUE u32, ms) is still accepted, so an existing config that
spells it parses and creates exactly as before, and conn_spec_t::keepalive_ms still
emits it. Nothing reads it: it is an unknown pair to the universal parse, dropped like any
other (#1666). It used to be
stored in conn_settings_t::keepalive_ms, a field with no consumer that every transport
vertex carried; that field is gone. UDP is connectionless, TCP has its own, WS handles
PING/PONG at the protocol layer — a kind that ever needs a keepalive knob parses it from
its own config, like any kind-private key.
Refusals the endpoint owns, not a key¶
One creation refusal is not any single key’s: it is a property of the SPEC as a
whole at the /net/<module>/conn endpoint, so it sits below the generated table
rather than in it (tools/check_config_keys.py derives that table’s rows from the
keys the source reads, and this rule is bound on an ABSENT key).
SPEC written to |
status |
why |
|---|---|---|
no |
|
Nothing can carry the connection’s bytes: with no |
The rule outlives the spelling it was written against. Before S7 retired the
/net:children[] creation door, an absent kind meant “resolve the module from the
staging”, so this refusal read as “no staging matched”. At the conn endpoint the
module comes from the PATH, so an absent kind is no longer a module selector at
all — and the refusal binds one step later, on the link resolution, where it is
still exactly as live.
Kind-private keys¶
Each kind’s factory parses its own keys out of the raw config TLV. Nothing here is
a field on conn_settings_t, and no kind can read another kind’s key.
tcp — the LISTEN-side bus facet¶
key |
value |
applies to |
default |
meaning |
|---|---|---|---|---|
|
|
LISTEN |
|
Non-zero exposes the |
|
|
LISTEN |
|
Concurrent-peer admission cap (RFC-0006); a connection beyond it is accepted and immediately closed. |
|
|
DIAL + LISTEN |
|
The peer liveness window (#838): how long a peer may fail to take bytes before it is treated as broken. It bounds every send (and the write-mutex hold it takes) — a broadcast record gets |
The first two are ignored on a DIAL: a client has exactly one peer, itself. liveness_window
applies to both roles — a dialled client can be stalled by its server just as a listener can by
a peer.
ws — the same keys, verbatim¶
key |
value |
applies to |
default |
meaning |
|---|---|---|---|---|
|
|
LISTEN |
|
As |
|
|
LISTEN |
|
As |
|
|
DIAL + LISTEN |
|
As |
|
|
DIAL + LISTEN |
|
Bytes. The pre-auth opening-handshake request budget (#934): the most an unauthenticated host — one that has completed a TCP connect and nothing else, no ACL, no subscription, no router — may make this node accumulate before its HTTP Upgrade request (LISTEN) or |
udp — no kind-private keys¶
The udp factory constructs no config reader at all: addr/port/max_frame from
the universal set, plus the role its module fixes, are the whole of its
configuration. A DIAL binds an ephemeral
local port and targets addr:port; a LISTEN binds port (or an OS-granted one when the
key is spelled 0) and learns its peer from the first inbound datagram’s source.
This is a checked claim, not an omission — see How this page is kept true.
can — bus identity and ingress bounds¶
Of the six transport factories in this tree, can is the only one that ignores
conn_settings_t outright — its lambda takes the record as an unnamed parameter — so
addr, port, keepalive and max_frame do nothing on a can connection. (kind
still matters — it is consumed by the connection vertex itself, to select this factory
and to cross-check the module the SPEC was written to. A bus has no dial/listen
asymmetry, so can is ONE module for both roles.)
key |
value |
applies to |
default |
meaning |
|---|---|---|---|---|
|
|
both |
— (required) |
The SocketCAN interface ( |
|
|
both |
— (required) |
This node’s id, the |
|
|
both |
|
Protocol-version prefix, the top 4 bits of the CAN ID, so distinct versions occupy disjoint arbitration bands. Above |
|
|
both |
empty |
The path advertised for this node’s groups. Same spelling as the |
|
|
both |
|
Non-zero selects CAN-FD framing (≤64 B data fields) instead of classic (≤8 B). |
|
|
both |
|
Peer liveness window (ADR-0044): a peer silent longer than this leaves the enumeration. |
|
|
both |
|
Live reassembly-group ceiling. |
|
|
both |
|
Ceiling on data slices parked awaiting their advertise. |
|
|
both |
|
RX staleness window: a parked slice or an incomplete group untouched this long is reclaimed, so a lost advertise cannot pin one forever. |
The two count caps and the pmr resource behind them are the ingress-bounding seam;
the byte-level story is on can.
quic and webtransport — the TLS material¶
Two modules, one identical key set, both in the separate libtracer_quic target.
TLS material is app-owned; the SPEC never carries a file path. A creation SPEC
can arrive from any writer, a remote one included, so the certificate, the private
key and the CA bundle come from the application: it builds a table of named
tr::net::tls_profile_t (name, ca_file, cert_file, key_file) and hands it to
the factory at registration —
net.register_transport_type("quic", tr::net::quic_transport_factory(profiles)). The
factory keeps the span, not a copy, so the table and the strings it views must
outlive the factory and every transport it builds; a constexpr table of string
literals costs no RAM at all. The SPEC can at most name one of those profiles:
key |
value |
applies to |
default |
meaning |
|---|---|---|---|---|
|
|
DIAL + LISTEN |
empty ⇒ the profile named |
The app profile this link uses. A name the app’s table does not hold answers |
|
|
DIAL |
|
DEV ONLY, and requires the build capability |
webtransport reads the same two, with the same meanings, plus two keys quic
has no use for — it is the only kind here with an HTTP layer, so it is the only
one with a resource to name and the only one with an H3 handshake to bound:
key |
value |
applies to |
default |
meaning |
|---|---|---|---|---|
|
|
DIAL + LISTEN |
empty ⇒ the profile named |
The app profile this link uses; the same rules as |
|
|
DIAL |
|
DEV ONLY, and requires |
|
|
DIAL + LISTEN |
|
Bytes. The pre-auth HTTP/3 handshake budget (#1408): the most a peer that has completed a QUIC handshake and nothing else — no session, no ACL, no subscription, no router — may make this node accumulate before its H3 material is refused. |
|
|
DIAL |
|
The extended CONNECT |
tools/gen-dev-cert.sh emits a self-signed pair for a profile’s LISTEN side.
The keys ca, cert and key no longer exist. Through v0.16 they carried
filesystem paths straight from the SPEC. A SPEC that still carries any of them is
now refused with TYPE_MISMATCH — the same answer as an unknown profile, before
any file is opened — rather than skipped like an unknown pair: skipping would move a
stale dial that pinned a private CA onto the system trust store without a word.
Migration: move
ca/cert/key into the app’s link config — a tls_profile_t table passed to the
factory — and, if more than one is needed, select it with tls.
A dial to the wrong resource is not a distinguishable failure: the server refuses
the CONNECT, the session never establishes, and creation answers TRANSPORT_DOWN — the
same status a certificate rejection gives. That holds for a well-formed path
naming a resource the server does not serve; the one case taken out of it by
#1039 is a path that is not origin-form at all, which is refused at creation
with TYPE_MISMATCH rather than dialled. Before #1023 there was no key at all
and the factory hard-coded /, so a SPEC could reach only a root-served session
and any other server needed the direct constructor plus provide_link. On the
LISTEN side, webtransport_transport_t::session_path(out) writes the :path the
accepted CONNECT named into a caller buffer — an observation, never an admission decision.
Certificate trust on a SPEC-created dialer¶
Five points, in the order an integrator meets them.
The default is verify. A quic or webtransport dialer created from a SPEC
with no profile anchor validates the peer’s certificate against the system trust
store, and a certificate that does not chain to it is refused: the handshake fails
and creation answers TRANSPORT_DOWN. Anything dialing a self-signed peer must say so,
with a profile whose ca_file certifies it or (on a build with kAllowInsecureTls) with
insecure = 1. This is a change of
behaviour, not a restatement of one: before #918 the DIAL branch hard-coded no-verify
and returned before the kind-private parse ran at all, so every SPEC-created dialer
skipped validation and no config key existed that could change it.
core/tests/quic_test.cpp and core/tests/webtransport_test.cpp drive all five legs —
no key, insecure = 1, a profile whose anchor is the peer’s own cert, insecure = 0,
and a profile with an unrelated bundle that is genuinely consulted and still refuses.
insecure wins when both are set. The credential is built with a single
if (insecure) … else if (!ca.empty()) …, so insecure = 1 together with a profile
anchor is a no-verify dial and the bundle is not consulted. That is deliberate: it
matches the quic_dial_tls_t contract the direct-construction path already had, and
it errs toward the mode the operator wrote down explicitly rather than toward a
silently half-applied one.
Malformed insecure fails secure. Every way of getting the key wrong resolves
to verify, because the reader returns “absent” and the field keeps its false
default: the key omitted, the value sent as a NAME instead of a VALUE, an empty
VALUE payload, and — because a VALUE is little-endian — a wide payload whose
first byte is zero, which is what a big-endian 1 looks like on the wire. There
is no spelling of a broken insecure that turns validation off.
insecure = 0 is not a weaker opt-out. It is the explicit spelling of the
default, and it verifies.
insecure = non-zero requires a build capability. A SPEC is a wire write, and a
node without an ACL policy accepts one from any connected peer, so whether the key is
honoured at all is decided by the build: config_t::kAllowInsecureTls, default
false. On a default build a SPEC carrying insecure = non-zero is refused at
creation with PERMISSION_DENIED, on either role, and counted
(tr::net::quic_insecure_refusals() / tr::net::webtransport_insecure_refusals()).
It is never downgraded to a verifying dial and never honoured. ca is the way to
reach a self-signed peer on such a build. A development build that needs the key binds
static constexpr bool kAllowInsecureTls = true; in its config_override.hpp; the
checked-in preset core/tests/insecure-tls/ does exactly that, and the quic CI
workflow runs the QUIC and WebTransport tests under it as well as under the default.
Only the SPEC is gated: an application that constructs a transport itself with
quic_dial_tls_t{.insecure_no_verify = true} has made that choice in its own code.
Why these are not conn_settings_t fields¶
conn_settings_t carries only the keys every transport kind shares. That
leanness is a ruling, not an accident (ADR-0043 §5): a kind’s private configuration
is parsed by that kind’s own factory, inside its own module.
The reason is the module boundary. quic lives in a separate link target; a device
that does not link it contains zero QUIC schema, no msquic reference and no feature
macro. Putting tls/insecure on the shared record would put TLS
vocabulary into the connection settings of a 16 KB MCU that will never speak TLS —
and it would grow once per kind, forever, for keys no other kind can use.
So the answer to “where do I add my kind’s new key?” is: in your factory, read
out of the raw config TLV it already receives, and in a block on this page. Not on
conn_settings_t.
Pitfalls¶
A typo is silent. No status distinguishes “key absent” from “key misspelled” from “key sent with the wrong TLV type” — all three take the default. The connection comes up looking healthy.
canignores the universal keys. Settingportormax_frameon acanconnection changes nothing; its identity isifname+node.keepaliveis accepted and ignored. It is not stored anywhere (#1666); no transport in the tree reads it.backoffandconnect_timeoutare dormant. They parse, they land inconn_settings_t, and nothing reads them yet.max_framecannot be used to buy headroom. It is TIGHTEN-ONLY: every framed kind resolves it throughlength_prefix_framer::configured_cap, which clamps a non-zero value to the 16 MiB protocol default, somax_frame = 32 MiBstill tears down a 20 MiB frame as malformed (#1035). It is an ingress bound you can clamp, never loosen — a config-writable key must not be able to widen what an unauthenticated peer can make the node buffer.udptightens too, for its own reason: a datagram cannot exceed 64 KiB, so a value above that is simply inert there. If a deployment genuinely needs larger frames, the 16 MiB ceiling is a source-level constant (length_prefix_framer::kDefaultMaxFrame), not a knob.peer_namedis off by default, so a SPEC-createdtcp/wslistener is a broadcast link and one request over it draws one reply per peer.peer_namedmay be REFUSED outright, not downgraded. A target that does not carry the ADR-0044 bus module (tr::graph::default_config_t::kBusLinks = false, the default since v0.17.0 — see transport.md §”Closing the bus module out at build time”) carries no peer-named tier at all, so both stream factories answerpeer_named = 1withTYPE_MISMATCHand create no connection. The status is deliberately the permanent one rather than the transientTRANSPORT_DOWNa failed bind gets: no retry will make that build grow a bus facet. Serving the key as if it had said0would be worse than either, because the listener’s own per-frame tier select reads its constructed mode — a listener demoted only at the router would keep delivering peer-named into a sink nothing installed.The builder types the universal keys, not the kind-private ones.
conn_spec_t’s named setters (#902) makekind,addr,portand the four u32s unmisspellable, and they replaced the sixteen hand-written emitters that used to exist. (Therolesetter is gone with the key: S7 made the role positional — it is the module in the endpoint’s path.) A kind’s PRIVATE keys still go through the generictext/u8/u16/u32/flagpairs — the builder cannot know them without the coupling ADR-0043 §5 forbids — so for those, a key’s spelling is still only as good as the string literal next to it.
How this page is kept true¶
Every key table above sits inside a marker block naming the source file that reads
it, and tools/check_config_keys.py derives the same information from that file —
it finds each config_reader_t construction and reads the accessor calls
on it, so the key and its wire value type come from the code, not from a
maintainer’s memory. The gate fails on three things: a key the source reads and the
page omits, a key the page lists and the source no longer reads, and a source file
that reads connection config with no block on this page at all. The last one is
what keeps the sweep honest — a new kind cannot be added with its keys documented
nowhere, and udp’s “no kind-private keys” is a derived fact rather than a claim.
The scope of that gate is connection config, and the scope is deliberate: the
creation-SPEC envelope and the SUBSCRIBER QoS parse in core/src/graph_fields.cpp read
their grammar through the same shared config_reader_t since
#985, so that file is
explicitly excluded from the sweep — its keys are not connection config — and the
ACL walk constructs no reader at all.
tools/check_config_keys.py # the gate
tools/check_config_keys.py --list # the derived inventory, one key per line