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, or the superseded write /net:children[] += SPEC{type, name, config} — and everything the new link needs is in that config SETTINGS TLV. Its keys come in two families. The universal ones (kind, addr, port, role, …) 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 cert/key/ca/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, and the two keys most worth reading before you ship are ca and insecure: 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 append, 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 (type, 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 VALUE payload whose size is not exactly the width this table gives (an empty payload being that rule’s trivial case). A u16 port sent as four bytes reads as absent rather than silently dropping its high bytes, and a u32 keepalive sent 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 NAME where 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.

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;

// The one-call form — the 90% case.
graph.write(*path_t::parse("/net:children[]"),
            tr::net::conn_spec("client", "up", conn_role_t::DIAL, 8080, "ws", "127.0.0.1"));

// The builder, for the rest: universal keys by name, kind-private keys as pairs.
graph.write(*path_t::parse("/net:children[]"), conn_spec_t("listener", "srv")
                                                   .role(conn_role_t::LISTEN)
                                                   .port(8080)
                                                   .kind("ws")
                                                   .max_frame(4096)
                                                   .flag("peer_named", true)   // ws-private
                                                   .u32("max_peers", 8)        // ws-private
                                                   .view());

// The RFC-0014 creator-endpoint spelling (S2b): the module in the PATH fixes both the
// transport and the role, so the SPEC carries neither a `type` nor a `role` — the same
// config keys, one door down.
graph.write(*path_t::parse("/net/ws-server/conn"), conn_spec_t("srv")
                                                       .port(8080)
                                                       .max_frame(4096)
                                                       .flag("peer_named", true)
                                                       .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 config at all — that is the provide_link-staged spelling, where the module comes from the staging rather than from a kind.

  • There is no module key, and the builder invents none. A SPEC names its module through kind together with role; see the kind row 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

kind

NAME utf-8

both

empty

Selects the transport factory (udp, tcp, ws, or any kind registered through register_transport_type) and, via its register_module declaration for this role, the module the connection mounts under — resolved before any staged link is consulted (#883), so a provide_link staging that merely shares the leaf NAME cannot capture the creation. Empty is the provide_link spelling: the module then comes from the staging, and a leaf NAME matching two or more stagings is refused TYPE_MISMATCH (nothing in the SPEC says which was meant). An absent kind matching no staging at all is refused TYPE_MISMATCH too (#1062): the config is missing a required field — the addr/port precedent — and never answers NOT_FOUND, whose wire form tr::path::not_found RFC-0014 reserves for an absent creator endpoint (the creatability probe). A staging under the resolved module still wins over construction; one under a different module is a different connection and is left untouched. A kind with no module declared for this role fails creation with SCHEMA_NOT_FOUND (ADR-0073 §4) — module resolution runs first, so that check precedes both the staging lookup and the factory lookup, and it fires even when a link is staged under the matching leaf NAME. An unregistered kind (no register_transport_type) fails the same way only when construction is actually reached: a staging under the resolved module short-circuits ahead of the factory lookup, so a kind declared purely to disambiguate two stagings needs a register_module and no factory at all.

addr

NAME utf-8

DIAL

empty

Peer address, IPv4 dotted-quad. A DIAL with it empty answers TYPE_MISMATCH in all five socket factories — the three built-ins (udp, tcp, ws) share one precondition helper, and quic/webtransport repeat the check. can never reads it.

port

VALUE u16

both

absent

Peer port on a DIAL, bind port on a LISTEN. On a DIAL, 0 answers TYPE_MISMATCH in the same five socket factories — there is no such thing as dialling the ephemeral port. On a LISTEN the key is what is required, not a nonzero value: an absent key answers TYPE_MISMATCH (the config is missing a required field), while an explicit port = 0 is the EPHEMERAL request — the OS picks a free bind port and the grant is read back off the constructed link with local_port() (#1362). Before that the LISTEN arm rejected 0 as if it were an omitted key, which conflated “you forgot the port” with “you do not care which port” and left an in-band-created listener no way to ask for one. can never reads it.

role

VALUE u8

both

the child type’s default

0 = DIAL, non-zero = LISTEN. Overrides the default the catalog type carries (client = DIAL, listener = LISTEN), and selects which declared module the connection mounts under.

keepalive

VALUE u32

both

0

Keepalive interval in ms. Nothing reads it: conn_settings_t::keepalive_ms has no consumer outside the parse that fills it. UDP is connectionless, TCP has its own, WS handles PING/PONG at the protocol layer.

max_frame

VALUE u32

both

0

Per-connection inbound frame cap in bytes, honoured by five of the six kinds — tcp, quic and webtransport read it off their u32 length prefix, ws off the RFC 6455 header, udp off the received datagram’s length (one datagram = one frame). 0 = the 16 MiB protocol default on the framed kinds, and udp_transport_t::kMaxDatagram (64 KiB) on udp. It only ever tightens, on every kind that reads it. On the four framed kinds each transport resolves the configured value through length_prefix_framer::configured_cap0 → the 16 MiB default, otherwise min(value, 16 MiB) — so a config-writable key can narrow what the node buffers off the wire but never widen it (#1035); the effective cap is then further bounded by the injected backend’s max_segment_size(). On udp it can only tighten for a different reason — a datagram cannot exceed 64 KiB, so a larger configured value is inert. A declared length over the cap is refuse-and-close on the framed kinds (malformed_rx(), then the link is torn down: a desynced stream cannot be re-framed) and refuse-and-continue on udp (malformed_rx(), socket unaffected — datagrams need no resync). A length at the cap is legal and delivered whole. On quic and webtransport the configured value bounds egress as well as ingress (#1409): a local send over the cap is shed with dropped_tx() and the link stays up, rather than being put on the wire for a conformant peer to count malformed_rx on and tear the connection down. can (its own fragmentation) does not read it.

backoff

VALUE u32

DIAL

0

Self-heal retry interval in ms (RFC-0014 §4), consumed by the S5 liveness engine (self_heal_link_t) on a kind registered self_heal_dial. 0 = the engine’s default (kDefaultBackoffMs, 1000 ms). On a kind not opted in (today: all the built-ins) it is parsed but has no consumer.

connect_timeout

VALUE u32

DIAL

0

How long one dial attempt waits for UP, in ms (RFC-0014 §4) — the S5 engine’s bound on an op’s auto-wake wait, and handed to the kind’s factory in its parsed settings. 0 = the engine’s default (kDefaultConnectTimeoutMs, 5000 ms). Same opt-in scope as backoff.

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

peer_named

VALUE u8 (flag)

LISTEN

0

Non-zero exposes the bus_link_t facet (ADR-0044): each accepted peer gets its own return-route identity and the connection’s :children[] enumerates live peers. Without it a listener is a broadcast link — send fans out to every open peer and no peer is individually addressable.

max_peers

VALUE u32

LISTEN

0

Concurrent-peer admission cap (RFC-0006); a connection beyond it is accepted and immediately closed. 0 is no longer uncapped (#1295): it takes the liveness window’s own ceiling, window ÷ 100 ms100 on the default 10 s window — and a larger request is clamped to that ceiling. The cap is the denominator a directed send’s bound divides by, so an uncapped server had no bound at all; to admit more peers, widen liveness_window.

liveness_window

VALUE u32

DIAL + LISTEN

0

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 window ÷ peers-in-this-round and a directed record window ÷ max_peers (#1295), both floored at 100 ms — so a stalled peer can no longer freeze the sending thread, and N stalled peers cost the node one window rather than N. Three consecutive stalled records to one peer, or one that half-reached the wire, close that peer. 0 = the conservative 10 s default clamp, never “unbounded”. It also SIZES max_peers above. The number is the deployer’s, exactly as connect_timeout and CAN’s peer_ttl are; it converges into RFC-0014 §S5’s single liveness contract.

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

peer_named

VALUE u8 (flag)

LISTEN

0

As tcp: the ADR-0044 per-peer identity facet instead of a broadcast link.

max_peers

VALUE u32

LISTEN

0

As tcp: the concurrent-peer admission cap, with 0 resolving to the liveness window’s ceiling rather than “uncapped” (#1295).

liveness_window

VALUE u32

DIAL + LISTEN

0

As tcp: the peer liveness window every send is bounded by (#838). The shipped case here is a throttled background browser tab that stops reading — it now costs counted frames and its own session, not the server’s sending thread.

max_handshake

VALUE u32

DIAL + LISTEN

0

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 101 response header block (DIAL) is refused. 0 = the 16 KiB transport_ws_server::kMaxHandshakeBytes default, and the key is tighten-only: a larger value is clamped to that default, because a config-writable key must never raise a pre-auth bound. The budget is a total-request one, not a per-read one, and it is enforced before the bytes that would exceed it are copied — over budget ticks malformed_rx and closes the link (count-then-close). It does not bound what a server pipelines behind its 101; those are frame bytes under max_frame.

udp — no kind-private keys

The udp factory constructs no config reader at all: addr/port/role/max_frame from the universal set 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 and role still matter — they are consumed by the connection vertex itself, to select this factory and to resolve the module the vertex mounts under.)

key

value

applies to

default

meaning

ifname

NAME utf-8

both

— (required)

The SocketCAN interface (can0, vcan0). Empty answers TYPE_MISMATCH; an interface the kernel will not open answers TRANSPORT_DOWN.

node

VALUE u16

both

— (required)

This node’s id, the node band of the 29-bit CAN ID. Required — an absent key answers TYPE_MISMATCH, and so does a value above 8191 (13 bits).

version

VALUE u8

both

0

Protocol-version prefix, the top 4 bits of the CAN ID, so distinct versions occupy disjoint arbitration bands. Above 15 answers TYPE_MISMATCH.

path

NAME utf-8

both

empty

The path advertised for this node’s groups. Same spelling as the webtransport kind’s path, unrelated meaning — that one is an HTTP URL path. Kind-private keys cannot collide at parse time; the collision is in the reader’s head.

fd

VALUE u8 (flag)

both

0

Non-zero selects CAN-FD framing (≤64 B data fields) instead of classic (≤8 B).

peer_ttl_ms

VALUE u32

both

3000

Peer liveness window (ADR-0044): a peer silent longer than this leaves the enumeration.

max_groups

VALUE u32

both

0

Live reassembly-group ceiling. 0 = uncapped by this key; overflow evicts the oldest group and ticks dropped_groups.

max_pending

VALUE u32

both

0

Ceiling on data slices parked awaiting their advertise. 0 = uncapped by this key; overflow evicts the oldest and ticks dropped_rx.

rx_ttl_ms

VALUE u32

both

0

RX staleness window: a parked slice or an incomplete group untouched this long is reclaimed, so a lost advertise cannot pin one forever. 0 means track peer_ttl_ms — never “disabled”.

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. Two keys are the LISTEN-side served credential; two are the DIAL-side trust decision.

key

value

applies to

default

meaning

cert

NAME utf-8

LISTEN

— (required)

PEM server-certificate path. Absent answers TYPE_MISMATCH; a path msquic will not load answers TRANSPORT_DOWN (the listener did not come up).

key

NAME utf-8

LISTEN

— (required)

PEM private-key path matching cert. Same two failures.

ca

NAME utf-8

DIAL

empty ⇒ the system trust store

PEM CA-bundle the peer’s certificate is verified against, instead of the system trust store.

insecure

VALUE u8 (flag)

DIAL

0

DEV ONLY. Non-zero skips server-certificate validation entirely.

webtransport reads the same four, 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

cert

NAME utf-8

LISTEN

— (required)

PEM server-certificate path.

key

NAME utf-8

LISTEN

— (required)

PEM private-key path matching cert.

ca

NAME utf-8

DIAL

empty ⇒ the system trust store

PEM CA-bundle to verify the peer against.

insecure

VALUE u8 (flag)

DIAL

0

DEV ONLY. Skips server-certificate validation.

max_handshake

VALUE u32

DIAL + LISTEN

0

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. 0 = the 16 KiB webtransport_transport_t::kMaxHandshakeBytes default, and the key is tighten-only: a larger value is clamped to that default (webtransport_transport_t::handshake_cap), because a config-writable key must never raise a pre-auth bound. On a LISTEN it bounds per-stream classification / HEADERS accumulation and the declared length of a HEADERS or unknown/GREASE frame, so an over-declaration is refused before one body byte is buffered; on a DIAL it bounds the CONNECT response’s field section the same way. Over budget is a statement about the peer, so the connection is shut down with the bad-request code — distinct from the two exhaustion dispositions, which are stream-scoped (#919) or count-then-close (refused_sessions(), #934) and are unchanged by this key. It does not bound frame-channel bytes; those are under max_frame.

path

NAME utf-8

DIAL

/

The extended CONNECT :path — which resource the WebTransport session is opened on (new WebTransport("https://host:port/here")). Empty is normalised to /. This is an HTTP URL path, not a libtracer graph path, and it is not the can kind’s path key (an advertised group path): kind-private namespaces do not collide, but the two spellings are identical, so read the section heading before copying a row. The LISTEN side of this kind serves every path — it validates :method/:protocol only — so the key matters when dialing someone else’s server (#1023). The accepted shape is origin-form: absent, empty (⇒ /), or /-prefixed. A non-empty value that does not begin with / answers TYPE_MISMATCH at creation, before any socket or TLS work, because an https request’s :path is /-prefixed in origin-form (RFC 9114 §4.3.1 / RFC 9113 §8.3.1) — nothing beyond that leading / is judged (#1039).

tools/gen-dev-cert.sh emits a self-signed pair for the LISTEN side.

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() reports the :path the accepted CONNECT named — an observation, never an admission decision.

Certificate trust on a SPEC-created dialer

Four points, in the order an integrator meets them.

The default is verify. A quic or webtransport dialer created from a SPEC with neither trust key 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 ca or 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, ca = the peer’s own cert, insecure = 0, and an unrelated ca 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 ca path 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.

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 cert/key/ca/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.

  • can ignores the universal keys. Setting port or max_frame on a can connection changes nothing; its identity is ifname + node.

  • keepalive has no consumer at all. It is parsed into conn_settings_t and no transport in the tree reads the field.

  • backoff and connect_timeout are dormant. They parse, they land in conn_settings_t, and nothing reads them yet.

  • max_frame cannot be used to buy headroom. It is TIGHTEN-ONLY: every framed kind resolves it through length_prefix_framer::configured_cap, which clamps a non-zero value to the 16 MiB protocol default, so max_frame = 32 MiB still 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. udp tightens 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_named is off by default, so a SPEC-created tcp/ws listener is a broadcast link and one request over it draws one reply per peer.

  • peer_named may be REFUSED outright, not downgraded. A target that closed the ADR-0044 bus module out (tr::graph::default_config_t::kBusLinks = false — see transport.md §”Closing the bus module out at build time”) carries no peer-named tier at all, so both stream factories answer peer_named = 1 with TYPE_MISMATCH and create no connection. The status is deliberately the permanent one rather than the transient TRANSPORT_DOWN a failed bind gets: no retry will make that build grow a bus facet. Serving the key as if it had said 0 would 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) make kind, addr, port, role and the four u32s unmisspellable, and they replaced the sixteen hand-written emitters that used to exist. A kind’s PRIVATE keys still go through the generic text/u8/u16/u32/flag pairs — 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.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