RFC 0024 — Bound paths: node-scoped vertex-ref source routing¶
Note
Status: accepted. This page is an accepted change proposal, kept as the record of why the specification reads as it does. Superseded in part by RFC-0029. RFCs are proposals and history, not the standard. The normative specification is Protocol v1 and the annexes its §3 incorporates; where an RFC and the specification differ, the specification wins. All RFCs, with their status, are listed in the ADR and RFC index.
Field |
Value |
|---|---|
RFC |
0024 |
Title |
Bound paths: node-scoped vertex-ref source routing |
Status |
accepted (2026-08-03, maintainer-ratified, comment window waived). Superseded in part by RFC-0029 (accepted 2026-09-30, §12.2): §4.1’s |
Author(s) |
AvatarSD (maintainer) — written up from the 2026-08-02 grill, in which the design below was ruled, not proposed |
Created |
2026-08-02 |
Comment window |
waived by default while solo-maintained (GOVERNANCE.md §”Errata, amendments, and the comment window”); invoke explicitly if outside input is wanted. Verified: |
Instrument |
Amendment. GOVERNANCE.md:53-54 — an erratum “may not alter the wire surface”; this adds a type code, a new frame shape and a new MUST for every forwarder, which GOVERNANCE.md:54 names as amendment territory by example (“a new frame shape”). |
Tracking issue |
|
Target spec version |
v1 itself. |
Scope |
v-NEXT. This RFC explicitly does not gate v0.7.0. |
Descends from |
#504 (client-originated binding — closed on the bench-gated ruling), #788 (the five questions, made normative here in §8) |
Numbering note. Numbering gaps and why they are not reused are recorded in the ADR and RFC index.
1. Summary¶
There is exactly one way to address a remote vertex today: spell the whole route as names, from
the caller’s own root, and make every hop re-resolve its own prefix out of those names
(§Path-as-route, CONTEXT.md). It is the right canonical form — self-describing, injective,
resolvable by a peer that has never spoken to you — and it is a poor repeat form, because the
second and thousandth operation to the same vertex re-transmit and re-resolve an answer both ends
already computed.
This RFC adds a second, optional normative path form and changes nothing about the first.
Canonical
PATH(0x06, NAME children) is UNTOUCHED. It stays the only form a cold peer can use, and it stays the mint key — the bytes a bound path is derived from, and the bytes a failed bound path falls back to. Its canonical-bytes property is what makes that key work (core/src/op_resolve_walk.hpp:660-669:path_lookup_keyreturnspath.body()unchanged when the PATH is canonical — the frame IS the key, ADR-0041 §3).PATH_REF(0x14) is the new form: a TLV whose body is a bare array of fixed 8-byte elements, one per host on the route. Each element is that host’s own reference to its next-hop connection vertex; the last element is the terminus vertex itself. Nothing in an element means anything anywhere but on the host that minted it — a bound path is a stack of node-scoped references, not a global name.An element is
(u32 vertex-map index, u32 generation), little-endian. Both widths are derived in §4.4, in the discipline RFC-0023 set: from the wire’s own widths and the RAM record, not chosen.Validation is existing machinery: a bounds check into the pinned, pointer-stable, insert-only vertex map (
core/include/libtracer/graph.hpp:60) plus a compare against the validate-on-use stamps that already exist (core/include/libtracer/graph.hpp:371,core/include/libtracer/child_registry.hpp:266-268). A failed validation never mis-routes: drop, NACK with the failing hop index, and the origin re-resolves canonically and re-mints.Binding is minted in-band, in the reply of an ordinary canonical op, with zero added request bytes (§7.5). No handshake, no registry, no per-hop table.
The whole thing is one sentence: the canonical path says who; the bound path says which of the things you already resolved.
2. What this is NOT¶
Stated first, because every reviewer’s first two questions are “isn’t this §E.1?” and “isn’t this the thing #504 closed?”
2.1 It is not RFC-0004 §E.1’s route-handle, and does not replace it¶
RFC-0004 §E.1 (RFC-0004:178-188) is delivery
compaction: a per-link u16 label that aliases an established delivery route, swapped at
every hop MPLS-style, advertised in-band, self-healed by HANDLE_NACK → re-advertise. It is
implemented (core/include/libtracer/route_handle.hpp, 0x11–0x13 at
docs/reference/05-protocol-tlvs.md:952-971) and it stays exactly as it is.
§E.1 label (stays) |
bound path (this RFC) |
|
|---|---|---|
what it names |
a per-link route alias |
a per-host vertex reference, stacked |
state model |
every hop holds |
no hop holds anything |
who pays |
each forwarder, per compact flow |
the origin (one path object) and the terminus edge |
wire cost |
10 B flat in hops ( |
4 + 8×H (H = hosts) |
direction |
producer → consumer (delivery) |
origin → target (any op) |
origination |
producer advertises |
origin requests, hops answer in the reply |
teardown |
link-scoped: |
nothing to tear down; a stale element fails validation and re-mints |
exhaustion |
label space saturates per link ( |
none — no allocation, no space to exhaust |
On raw bytes for a long-lived stream §E.1 wins and keeps winning (10 B beats 28 B at H=3).
This RFC does not compete for that traffic. It serves the traffic §E.1 structurally cannot: a
client’s repeated request to a dst (not a producer’s delivery), across hops that must stay
stateless (a forwarder holding no per-flow table is the property RFC-0004 §E.1 itself calls
“the cost, made precise”), and one-shot-shaped ops that never justify an advertise round.
Vocabulary rule (normative for the doc set). ~~”Label”~~ “Label”, unqualified, remains
RFC-0004 §E.1’s per-link u16 and nothing else. The new concept is a “bound path”, built of
“vertex ref (vref)” elements. A sentence that calls a vref a label is wrong (§11).
⚠ Narrowed by amendment 3 below (2026-08-15,
RFC-0027): the rule binds the unqualified word;
the qualified term “path label” is a third, distinct concept and is ratified.
Amendment 3 (RFC-0027, 2026-08-15) — the vocabulary
rule binds the UNQUALIFIED word only; “path label” is ratified as a distinct term. Maintainer
ruling of 2026-08-15, taken as part of accepting RFC-0027 (that document’s §11.1 collision 1,
resolution (a) qualify; the alternative, renaming RFC-0027’s concept, was rejected because it
costs the MPLS reading the design leans on and would be re-invented as “label” in the first
sentence of every discussion). Instrument: amendment — this alters a clause marked “normative
for the doc set”. Comment window waived by default while solo-maintained
(docs/implementations.md:13 still reads _(none yet)_, so the waiver’s revert trigger has not
fired). The normative content, in full:
Unqualified “label” is unchanged: it means RFC-0004 §E.1’s per-link
u16delivery-route alias and nothing else. Every existing sentence in the doc set stays correct as written.“A sentence that calls a vref a label is wrong” stands unchanged. A vref is not a label of any kind, qualified or not.
“Path label” is a third, distinct term, ratified by RFC-0027: a 32-bit, per-host, per-path-element alias (16-bit slot index + 16-bit generation) minted passively on a reply. It MUST always be written qualified — never as bare “label” — and it is neither §E.1’s link label nor this RFC’s vref.
The doc set therefore carries three related-but-distinct concepts, and the glossary separates them in one place: §E.1’s label (per-link,
u16, swapped at every hop), this RFC’s vref (per-host, 8 B, carried end to end, swapped by nobody), and RFC-0027’s path label (per-host, 4 B, per path element, read only by the host that minted it).No wire surface changes. This amendment is vocabulary only: no type code, no field, no MUST about bytes.
PATH_REF/PATH_REF_REVERSEand every clause of §§4–7 are untouched.
2.2 It is not the resolve-once memo #504 closed¶
#504 closed on a measurement, and the
measurement is binding on this RFC: the per-delivery by_name scan deliver_remote pays is
9–12 ns/delivery, and only at W≈32 registries — “at W≈4 the arms overlap; nothing there” —
and the one-slot memo that recovered exactly that delta regressed the existing four-link
reply-spread shape by 140–311 ns/write, which is a latency regression on a shipped shape,
which is a reject.
So the local-terminus nanosecond win is NOT the motivation of this RFC, and no version of
this document may claim it. The prize is §3: wire bytes on constrained links, the per-hop mount
descent on multi-hop routes, and MCU cycles. The 9–12 ns figure appears here once, as the thing
that is not being sold. #504’s rejection also supplies this RFC’s acceptance bar directly (§8):
reply-spread is a mandatory must-not-regress arm.
3. Motivation¶
3.1 The canonical form re-pays a resolution both ends already made¶
A remote address is the full path from the caller’s root, walking through transport vertices
(CONTEXT.md §Path-as-route). Each hop strips its whole mount run — net/<module>/<name>,
RFC-0014 S2a / ADR-0061’s
strip-K descent — and forwards the residual. Concretely, per hop, the forwarder runs
resolve_mount_at (core/src/fwd_router.cpp:331), which folds a digest chain over the leading
segments and compares candidate mount slots, falling back to resolve_mount_deep
(core/src/fwd_router.cpp:270) when the address is longer than the in-place window or split by a
rope; both sit on resolve_mount_by (core/src/fwd_router.cpp:200). Every hop of every operation
re-derives the same split from the same bytes.
That is correct and it is the right default. It is also, for the Nth identical op, an answer recomputed from scratch on both sides of every link.
3.2 The bytes, derived¶
Cost rule: a canonical PATH body is concatenated NAME TLVs, each costing 4 + len
(docs/reference/05-protocol-tlvs.md:377), plus the 4-byte PATH header. Mount runs are three
segments at today’s width (ADR-0061). Using the same mount-run shapes RFC-0023 priced
(RFC-0023:185-188): net/ws-client/board-01 = 32 B of body, net/can/c0 = 20 B, a
/sensor/temp residual = 18 B.
route |
canonical |
hosts H |
|
saved |
|---|---|---|---|---|
direct link, |
4 + 50 = 54 B |
2 |
20 B |
34 B (−63 %) |
one forwarder, |
4 + 70 = 74 B |
3 |
28 B |
46 B (−62 %) |
two forwarders |
4 + 90 = 94 B |
4 |
36 B |
58 B (−62 %) |
RFC-0018’s worked 5-segment |
49 B |
2 |
20 B |
29 B (−59 %) |
The shape to notice: canonical grows with segments, bound grows with hosts, and a host costs one mount run (≥ 3 segments ≥ 15 B today) but exactly 8 B bound. The ratio is roughly constant at ~2.6× because the mount run is the dominant term at every hop.
The stream figure, honestly. RFC-0004:180 prices a 1 kHz 4-byte sensor over a 3-hop path at “~60 B of route on a 4 B payload (~16×)”. Bound: 36 B on 4 B = 9×. §E.1’s label: 10 B on 4 B = 2.5×. So on that specific traffic §E.1 remains the answer and this RFC is second-best — which is exactly why §2.1 keeps it.
3.3 Constrained links: the frame arithmetic¶
A classic CAN frame carries 8 payload bytes (docs/reference/14-can-transport.md:142), so an
address costs ⌈bytes / 8⌉ frames before any payload:
route |
canonical frames |
bound frames |
|---|---|---|
direct link (54 B / 20 B) |
7 |
3 |
one forwarder (74 B / 28 B) |
10 |
4 |
two forwarders (94 B / 36 B) |
12 |
5 |
Honest caveat, load-bearing: CAN itself is not the beneficiary. 14-can-transport.md:274
records the rule — “CAN always labels (no route fits in 8 bytes)” — so the CAN plane is already
served by the id↔path map and §E.1. The 8-byte frame is used here as the unit of a constrained
link: the beneficiaries are full-TLV links with small MTUs and no label plane (LoRa,
BLE-GATT, framed serial), and any link where the origin is an MCU counting bytes per op.
3.4 The per-hop work¶
Under a bound path a forwarder does not descend at all: read element i, bounds-check the index,
load the vertex pointer, compare one u32, egress. No digest fold, no segment compare, no
variable-length walk — the resolve_mount_* family (§3.1) is not entered.
MEASURED, with the forwarding car (§8.4’s condition of acceptance, discharged). A chain
bench routes the same FWD{WRITE} + RESULT traffic over H = 2/4/8 hops in both spellings, with
frames-per-direction asserted equal and the terminus value verified, so the only variable is how
the address is spelled. Taken as the 2→8-hop slope, each hop being one forward plus one
reply-forward:
arm |
H = 2 |
H = 4 |
H = 8 |
ns/hop (2→8 slope) |
|---|---|---|---|---|
canonical (mount descent) |
788 ns |
1374 ns |
2616 ns |
304.7 |
bound ( |
804 ns |
1274 ns |
2374 ns |
261.7 |
A bound hop is ~43 ns/hop cheaper than the canonical descent it bypasses (~14 %), and the saving is a slope: at H = 2 the two are level and the bound arm only pulls ahead as hops accumulate, which is exactly the shape a per-hop saving has. The bound arm’s exercise of the new hop is proven rather than assumed — a deliberately stale mid-chain generation drives the delivered count to 0, and the byte-identical frame with a sound generation delivers.
A first measurement of this claim, taken against an earlier build of the same car, came back
negative (bound 352 ns/hop vs canonical 313). That build is what §14.2’s withdrawal clause is
for, and the clause was not invoked because the cost was not the hop: it was two header walks
where one classification suffices, a duplicated op-byte read on every forwarded frame, and a
flatten that pulled the mint accumulation into the request path. None of the three is the
PATH_REF deref, and removing them left the structural argument standing where the number now
agrees with it.
4. The wire form¶
4.1 PATH_REF — type 0x14¶
0x14 is the first unassigned code in the fast-track range: 0x0F–0x13 are assigned and
0x14–0x1F are free (docs/reference/05-protocol-tlvs.md:901). A receiver that does not
implement this RFC treats it per the forward-compatibility rules that section names, i.e. it does
not crash — but it also cannot route, so §9.3 states the fallback obligation.
PATH_REF (0x14, PL=0, LL=0) {
; body: H bare 8-byte elements, in route order, no per-element framing
; element i, little-endian:
; u32 index — the minting host's vertex-map index
; u32 generation — that vertex's retirement generation at mint time
}
Element 0 is the origin’s own reference to the connection vertex for its first hop.
Element i (0 < i < H−1) is forwarder i’s reference to its connection vertex for hop i+1.
Element H−1 is the terminus host’s reference to the target vertex itself.
Each forwarder consumes element 0 and forwards the remainder — the same monotone shrink the canonical
dstperforms (RFC-0004 §B), so a bound path is loop-free by construction for the identical reason and needs no visited set.
4.2 The byte ledger — every byte, accounted¶
The maintainer directive for this RFC: account for every byte a PATH_REF-addressed operation
puts on the wire; any byte that cannot be justified from a measured bound or an existing wire rule
is removed from the format, not defended. The format below is what survived that pass.
Envelope — 4 bytes, and there is no fifth.
off |
bytes |
field |
why exactly this |
|---|---|---|---|
0 |
1 |
|
one byte is the TLV grammar’s own type width ( |
1 |
1 |
|
the grammar’s own options byte. PL = 0 and LL = 0, derived below |
2 |
2 |
|
the default length width ( |
4 |
8×H |
element array |
§4.4 |
opt.PLMUST be 0.PL = 1asserts “the payload is concatenated child TLVs” (reference/05:22), and it is not: it is a fixed-stride record array. A genericPL = 1walker would read the first four body bytes as a TLV header —type= the low byte of an index,opt= the next — and mis-frame the whole body. This is the same flip RFC-0018 makes toPATHfor the same reason (RFC-0018:36).opt.LLMUST be 0, andLL = 1is therefore never applicable to aPATH_REF.LL = 1buys a u32 length for bodies above 65 535 B (01-data-format.md:102); the normative element cap (§4.3) puts the maximum body at 2040 B. There is no reachablePATH_REFfor whichLL = 1is anything but two wasted bytes, so it is forbidden rather than merely unused.opt.TS/opt.CRare the enclosing frame’s business, not this TLV’s. APATH_REFnested in aFWDcarries no trailer of its own, exactly asPATHdoes not today.Alignment. With
LL = 0the payload starts at offset 4 (01-data-format.md:36— “the defaultLL=0case keeps payload at offset 4, naturally aligned for u32 access”), so every element field is u32-aligned whenever the TLV is. Receivers must still tolerate unaligned reads per that same line; the layout simply does not force one.
Rejected: per-element TLV framing. Wrapping each element as its own VALUE TLV costs
4 + 8 = 12 B per element:
H |
bare array (4 + 8H) |
per-element TLV (4 + 12H) |
overhead |
|---|---|---|---|
2 |
20 B |
28 B |
+40 % |
3 |
28 B |
40 B |
+43 % |
4 |
36 B |
52 B |
+44 % |
A per-child header exists to delimit a variable-length child — which is precisely why NAME
children carry one inside PATH. Elements are fixed-width: element i is body[8i .. 8i+8),
computed, not parsed. A header that delimits a record whose length is a constant is 4 bytes
buying nothing, so it is not in the format.
4.3 The element-count bound, derived (not chosen)¶
Two bounds, and the tighter one is not the normative one:
Reachable. A bound path is always minted from a canonical route (§6), so its host count is bounded by what a canonical
dstcan spell. The canonical body cap is 1024 B (reference/05:299-300) and a hop costs at least one 3-segment mount run —3 × (4+1) = 15 Btoday,3 × (1+1) = 6 Bunder RFC-0018’s packing. So H ≤ 69 today (68 runs + the terminus element) and H ≤ 171 under packing.Normative.
PATH_REFelement count MUST be ≤ 255, body ≤ 2040 B. This is RFC-0023’s discipline applied unchanged: 255 is the largest count for which every per-element quantity — the count, the largest index (254), a receiver’s per-element table dimension — fitsu8, and it sits above both reachable ceilings, so it is an encoding-independent ceiling rather than an artifact of whichever body grammarPATHcurrently uses.
The wire carries no element-count field: the count is length / 8, and a length not
divisible by 8 is tr::frame::invalid. A count field would be a byte (or two) restating what the
length already says — removed under the directive, not defended.
4.4 The element — 8 bytes, and why each 4 is exactly 4¶
The index: u32, derived from the RAM floor¶
The index addresses the pinned, insert-only vertex map. Its width is bounded by how many vertices can physically exist, so the derivation is a RAM floor:
The smallest vertex costs
sizeof(vertex_t), gated at 80 B on rv32 (core/include/libtracer/config.hpp:188) and 120 B on a 64-bit host (core/include/libtracer/config.hpp:176), enforced at compile time (core/include/libtracer/vertex.hpp:2944,:2947). ADR-0070:41 records that rv32 sits at exactly 80 with zero headroom — so 80 is a floor, not a budget.Add the index slot itself: one pointer, 4 B on rv32, 8 B on a host (§6.4).
Ignore, deliberately, the parent’s child-container slot and the key bytes — every one of them makes the floor higher, so leaving them out only strengthens the bound.
target |
floor B/vertex |
vertices in the largest plausible RAM |
as a power of 2 |
|---|---|---|---|
rv32 |
≥ 84 |
4 GiB address space ⇒ ≤ 51.1 M |
2^25.6 |
64-bit host |
≥ 128 |
2^32 vertices would need 549 GB |
— |
So u32 is unreachable on both targets: on rv32 by ~84× against the entire address space, on a host by needing more RAM than a single node has.
Why the next smaller honest width fails. u24 (16 777 216) × 128 B = 2.1 GB — reachable
on commodity hardware today, so it is a real ceiling rather than a formality; and 24 bits is not
a machine width, costing a mask and a shift on both sides of every deref for the privilege. u16
(65 536) × 80 B = 5.2 MB — reachable on an MCU, let alone a host, so it fails outright. u32 is
the smallest natural width that cannot be exhausted, which is the definition of derived.
The generation: u32, and it MUST NOT be narrower¶
The generation is the anti-mis-route guard — the only thing standing between a stale reference and a delivery into whatever now occupies that slot. Its width is not a space decision.
It is the width of the stamp that already exists.
graph_t::retire_generationreturnsstd::uint32_t(core/include/libtracer/graph.hpp:371);child_registry_t::mount_generationlikewise (core/include/libtracer/child_registry.hpp:285). A narrower wire field would be a truncating conversion of a live counter — aliasing every 2^width retires by construction, with no code anywhere doing anything wrong.Wrap arithmetic. A
u16generation wraps after 65 536 retires. A dynamic controller graph retiring one vertex per second wraps in 18.2 hours; at 10/s, 1.8 hours. Au32reaches 4.29 × 10⁹: 136 years at 1 retire/s, but only 49.7 days at 1000/s — so width alone is not the safety argument, which is why rule 3 exists.Therefore, normatively: the generation MUST saturate, never wrap. This is #603’s ruling transposed. The
route_handleu16label allocator wrapped, andcore/src/route_handle.cpp:175-177records what that cost: “a wrappednext_labelhanded out the reserved 0 and then re-issued 1, 2, … while those labels still aliased LIVE routes — a delivery on the reused label resolves the wrong route, which is a misroute, not a drop.” A wrapped generation is that same failure with the guard instead of the address: a stale vref validates falsely, and the operation lands on the vertex’s successor. On saturation a vertex becomes permanently unbindable and every mint for it falls back to canonical — the identical degrade the label allocator already takes at exhaustion (route_handle.cpp:175-187: return 0, record nothing, send the full route, “which always works”). With saturation, the wrap failure class is closed by construction, and 2^32 is then a statement about how long before a hot vertex stops being bindable, not about safety.
Byte order¶
Little-endian, both fields. This is not a choice: docs/reference/01-data-format.md:35 —
“little-endian for every multi-byte field.”
4.5 Rejected element shapes¶
u48index +u16generation (same 8 bytes, “spend the width where the cardinality is”). Rejected on both halves. The index gains nothing — u32 is already unreachable by 84× on rv32 and by 549 GB on a host (§4.4), so the extra 16 bits buy headroom above a ceiling that cannot be approached, andu48is not a machine width. The generation loses the property the element exists for: at 65 536 it wraps in under a day of ordinary churn, a wrapped stale ref validates falsely, and the result is a wrong-route delivery — the exact failure class #603 fixed by saturating the label allocator. Trading the guard’s width for headroom above an unreachable ceiling is the trade that produced #603 in the first place.A raw pointer on the wire. Rejected on unforgeable-validation grounds: there is no bounds check a receiver can apply to a peer-supplied address. Any 32/64-bit value is a syntactically-valid pointer, so the guard degenerates to “dereference and hope” — a crash or a forge primitive handed to whoever can put bytes on a link. An index is checkable against a cardinality the receiver knows. The reference implementation already refuses this shape internally, for the weaker in-process case:
vertex_handle_t“exposes nooperator*or raw-pointer accessor” (core/include/libtracer/graph.hpp:58-59); putting on the wire what is not exposed to a local caller is not arguable. It is also 8 B on a host where 4 suffices.A globally unique vertex id (UUID / node-key + local id). Rejected as a different design: it would need a mint authority, a resolution table at every hop, and a global namespace — three structures this RFC exists to avoid. The node-scoped element needs none, because it is only ever read by the node that wrote it.
5. Validation — existing machinery, no new registries¶
5.1 The check¶
On receipt of a PATH_REF-addressed op, a host reads element 0 and:
Bounds-check
indexagainst its vertex-map cardinality. The map is pinned, pointer-stable, insert-only —core/include/libtracer/graph.hpp:60, and ADR-0057’s never-freed rule (graph.hpp:113) — so an in-range index always names a live allocation and the deref itself cannot fault. Out of range ⇒ reject (§5.3).Compare the generation against
graph_t::retire_generation(vh)(core/include/libtracer/graph.hpp:371). Mismatch ⇒ reject. That doc comment already states the contract this RFC leans on verbatim: a holder “records this alongside it and re-reads it before use: a mismatch means the path was retired (and possibly re-created for a DIFFERENT owner) since the resolution, so the cached answer must be discarded rather than delivered into whatever now occupies that path.”Authorize — §6. The same comment is equally load-bearing in the negative: “Callers must NOT cache an authorization decision this way — a generation match says the vertex is the same one, never that the caller may still act on it (ACL stays per-operation).”
Egress (or, at the terminus, apply the op).
Generations only ever move forward (retire_generation is bumped under retirement’s own
ordering, graph.hpp:367), so a stale element can only ever compare lower. It never becomes
valid again by waiting.
5.2 The three stamps, and which one the wire carries¶
core/include/libtracer/child_registry.hpp:266-268 states the set: the mount-shape generation is
“the third validate-on-use stamp, beside graph_t::retire_generation (a revived vertex) and the
slot tombstone (a departed link)”. All three remain in force under this RFC; the division is:
stamp |
catches |
where it lives under a bound path |
|---|---|---|
|
a retired-and-revived vertex |
on the wire — the element’s second u32 |
slot tombstone |
a departed link |
node-local — the egress slot the deref’d connection vertex resolves to is checked as it is used today; zero wire bytes |
|
the split point moving — a |
node-local, and structurally not applicable to the deref: a bound element names a connection vertex directly. There is no cached prefix split to go stale because there is no prefix. The stamp still guards the mint (§6.2) and every canonical-form op, unchanged |
That third row is the honest consequence of the design, not an exemption claimed for it: the
hazard mount_generation exists for — “bind a label through mount net/ws/s, then register
net/ws/s/rack, and a full FWD resolves against the NEW, deeper mount while a COMPACT riding
the old label still dereferences the binding made against the old split”
(child_registry.hpp:268-270) — requires a cached split. A vref caches a vertex, and a vertex
is not a split.
5.3 Failure is a drop, never a mis-route¶
Normative. A host that cannot validate element 0 MUST NOT forward, MUST NOT apply the operation, and MUST NOT attempt any repair of its own (no re-resolution, no nearest-match, no retry against a different vertex). It drops the frame and returns a NACK.
The NACK carries the index of the failing hop — the position of the element that failed,
counted from the origin’s element 0, so the origin knows where the route broke rather than only
that it broke. Two spellings are open and the choice is deferred to implementation review
(§9.2): extend HANDLE_NACK (0x13) with an optional second child, or take a sibling code from
0x15–0x1F. HANDLE_NACK’s body is a bare VALUE label(u16) today
(core/include/libtracer/route_handle.hpp:427-429; docs/reference/05-protocol-tlvs.md:966-969)
and the peek path already tolerates a missing second child
(core/tests/fwd_frame_view_test.cpp:215-217 — “bare-label HANDLE_NACK has no child[1]”), so
the extension is additive; the argument against it is that reusing a route-handle control frame
for a non-label concept violates §2.1’s vocabulary rule.
Erratum 4 (#1260, 2026-08-14) — the NACK is scoped to the delivery arm, and the asymmetry is intended. (Numbered in the order raised; errata 1-3 are in §7.1.)
What the text says. The paragraph above reads “It drops the frame and returns a NACK”, with no arm named — every refused bound-form frame, forwarded or delivered alike.
What the behaviour is. The reference core returns the NACK on the one-element bound
WRITEdelivery arm only (the reverse-list delivery of §7.1 amendment 1: an addressedtr::path::invalidecho of the refusedPATH_REF). The pre-existing multi-element forward arm — a hop that cannot validate element 0 of a residual it was asked to forward — drops silently, exactly as it has since the forwarding car.Which change made them diverge. #1259. The forward arm shipped with the forwarding car, before any NACK spelling existed; the delivery arm is new, and it needs the echo for a second reason the forward arm does not have — the producer’s step-5 reclaim (
evict_route_edges) correlates that echo to retire the stale edge on its first post-mortem delivery.Why the asymmetry stands rather than being widened. A silent drop is already the conformant behaviour: §9.2 still leaves the NACK’s own spelling open, so the clause above is ratified-but-not-incorporated and the NACK only makes the origin’s recovery faster, never more correct. Extending it to the forward arm was priced: it is affordable only behind a
[[gnu::noinline]]helper, because inline it risks repartitioningroute_fwd_forward— already +208 B at #1259, and that exact hazard cost 12% there. That is real risk spent on a cold path for a latency improvement the origin’s canonical fallback already delivers.Why it is an erratum. It records which frames a shipped implementation emits and alters no wire surface — it adds no frame, removes none, and changes no byte of any conformance vector. It withdraws no ruling either: when §9.2’s spelling is settled the NACK may be widened by amendment, and this erratum is the record of where it is not emitted today.
The origin’s recovery is the one that already exists and is already known to work: fall back to the canonical form and re-mint. RFC-0004 §E.1’s self-heal in one line — drop, signal, re-establish — reached here without a re-advertise round, because the canonical path the origin still holds is the fallback (§1: canonical stays the mint key). This is also why §9.3 can make canonical support mandatory: every bound path has a canonical original by construction.
6. ACL — all existing machinery, stated as conformance requirements¶
No new access-control concept appears in this RFC. What follows is the existing machinery restated as requirements, because a route form that skipped a gate would be a capability, and a vref is an address, never a capability (#504’s own framing).
6.1 Mint is gated by the full existing check¶
Normative. A host MUST NOT mint a vref for a vertex the requesting caller could not have
reached canonically in the same operation. Since a mint rides an ordinary canonical op (§7), this
is automatic: the op already ran acl_allows at every gate on its way through
(core/src/graph.cpp:700), and a denial is PERMISSION_DENIED before any vref is produced.
The anti-enumeration property, stated: because denial happens at resolve time, no vref is ever minted for a destination an ancestor ACL hides. Probing a bound-path mint therefore yields exactly what probing the canonical form yields — exists + denied, never exists + here is a handle to it. A bound path cannot be used to discover a namespace its holder cannot already walk.
6.2 The hot path evaluates the same predicate at the deref’d vertex¶
Normative. Every operation arriving on a bound path MUST evaluate acl_allows at the
dereferenced vertex, for the operation’s own right, exactly as the canonical form does. A
generation match authorizes nothing (graph.hpp:367-369).
The evaluation is the shipped one: graph_t::acl_allows (core/src/graph.cpp:700) walks to the
nearest bearing ancestor lock-free and evaluates that vertex’s cached effective-ACE merge
through the kAceInherit projection —
ADR-0050, one pre-merged list, no
per-operation ancestor rebuild (graph.cpp:718-727).
Revocation is immediate; there is no snapshot to go stale. An :acl write marks the subtree
dirty (graph_t::mark_subtree_acl_dirty, core/src/graph.cpp:750) and the next check rebuilds.
A bound path holds no ACL state of any kind, so a revoked right takes effect on the very next
operation over an already-minted binding — the property graph.hpp:367-369 demands and the reason
this RFC stores nothing authorization-shaped.
6.3 Equivalence by construction — and a vector pair anyway¶
Path-form operations check acl_allows(target) and nothing else: graph_t::read
(core/src/graph.cpp:758), graph_t::write_impl (core/src/graph.cpp:994). A bound-form
operation dereferences to the same vertex_t* and calls the same function with the same right
and the same caller context, so the outcomes are identical by construction — there is no
second policy to keep in sync, which is the property that makes this section short.
A by-construction argument is not a test. Conformance MUST carry a paired vector set —
canonical and bound spellings of the same operation against the same graph — for allow and
deny, asserting byte-identical outcomes (RESULT bytes in the allow case;
ERROR{tr::access::denied} 0x0050, docs/reference/05-protocol-tlvs.md:561, in the deny case).
Named in §9.4. The reason to require it despite the argument is on the record: RFC-0014’s lesson —
two silent misroutes shipped because no test used the production wiring.
6.4 The one new structure, named honestly¶
This RFC adds no wire registry and no per-hop route table. It does require one node-local
structure that does not exist today, and the RFC would be dishonest not to name it:
graph.hpp:60’s “vertex map” is described as a map but stored as the ADR-0057 composite
vertex tree (core/include/libtracer/graph.hpp:1402-1413) — a tree of non-moving unique_ptr
allocations with no dense index. A dense, append-only vector<vertex_t*>, one slot appended
per registration, is therefore required to give an index meaning.
Its cost and its properties:
4 B/vertex on rv32, 8 B on a host — the figure already used in §4.4’s floor. Erratum (routing car): “
vector<vertex_t*>” above names the shape — dense, append-only, O(1) by index — not the container. A geometrically-growingstd::vectorholds up to twice the pointers it needs between doublings, and the reference core measured 15 B/vertex that way on the 512-vertex heap probe, nearly double the figure this clause prices. It stores the index in fixed blocks (std::deque) instead, which measures 8 B/vertex — the priced pointer and no unpriced headroom — while keeping the index O(1) and its elements non-moving. The cost model and the wire surface are unchanged; only the sentence naming a container was wrong.It is append-only, which the registration path already is (“vertices are added, never erased”,
graph.hpp:1405), so it introduces no new lifetime rule and no new invalidation event.It is node-local and unobservable on the wire; a peer never learns another node’s cardinality.
It is not a route table: its size tracks the graph, not the traffic, so it does not reintroduce the per-flow state §2.1 credits this design with avoiding.
The binding budget referenced in §7.2 is likewise an injected bound, never a magic number
— the shape route_handle_table_t already uses (core/include/libtracer/route_handle.hpp:163:
“unbounded — the default … A bounded host sizes it from its” own resources; enforced at
core/src/route_handle.cpp:184-187, which refuses a new flow and never an established one).
7. Minting a bound path¶
Three activation modes are ruled in, and they compose rather than compete.
7.1 (b) via (c): request in the op, answer in the reply¶
The primary path, and the only one that needs wire support.
The origin issues an ordinary canonical-form op and sets a bind-request flag (§7.5).
Each forwarding hop, as it forwards, notes the connection vertex it selected. On the way back, each hop appends its vref for the next hop to a
PATH_REFaccumulating on the reply — the mirror of the waysrcaccumulates on the way in (RFC-0004 §B, “prepends tosrc”), and equally a rope operation rather than a rewrite.The terminus appends its own reference to the target vertex as the last element.
The origin receives the complete forward vref list and stores it in its path object.
Erratum 1 (forwarding car, 2026-08-03) — a hop that cannot contribute MUST STRIP the answer.
Step 2 above says each hop appends its element; it did not say what a hop does when it cannot
(no connection vertex for the link, a saturated generation, a full list). “Forward the reply
unchanged” is the obvious reading and it is unsafe, so the clause is corrected rather than
left to implementers: a list that skips a hop is not a shorter route, it is a wrong one. The
origin consumes its own element (§4.1), the frame arrives at the non-contributing hop with
exactly one element left, and that hop — believing itself the terminus — dereferences an element
minted on a different host against its own vertex map, where the same index and generation are
an ordinary live vertex. That is a mis-route, which §5.3 forbids outright, and no stamp catches
it because the element is perfectly valid there. A hop that cannot mint therefore removes the
PATH_REF from the reply it forwards; the origin sees an ordinary reply, stays canonical, and
loses nothing but the optimisation. This changes no byte layout and adds no frame shape — it
constrains behaviour to close a mis-route class — so it is an erratum, not a further amendment.
Symmetry, ruled in. In the same round trip, each hop MAY also append its reverse-direction vref, so the responder learns the return list too. One round trip binds both directions. This is what makes the delivery direction (§7.4) free rather than a second exchange.
Erratum 2 (#1223, 2026-08-13) — the symmetry clause has no wire spelling, and §7.5’s own ledger contradicts it. The paragraph above is ratified prose. It says that a hop may bind the reverse direction; it never says where those bytes go, and the byte ledger written to account for them puts them somewhere they cannot arrive. This erratum records the gap and its consequence — it changes no byte layout, adds no frame shape and withdraws no ruling, so it is an erratum; what it records is that realizing the clause is an amendment.
The contradiction, stated exactly. §7.5 opens “Request: zero added bytes”, and that sentence is
already incorporated normatively — docs/reference/05-protocol-tlvs.md §0x14 §Minting reads
“costs zero added request bytes”, and docs/spec/v1.md §3 incorporates that section in full.
§7.5’s table then lists the reverse list under Reply, at 4 + 8H bytes, “only when the symmetry
option is exercised”. But a reply travels away from the responder. A reverse list that rides the
reply is read only by the origin, which already knows its own route out; the responder — the one
party the clause exists to inform — never sees it. So the ledger’s placement makes the clause
vacuous, and the clause’s purpose makes the ledger wrong. One of the two has to give, and neither
can give without moving the wire surface.
Four spellings exist. Each contradicts something already incorporated:
(a)
srcbecomes aPATH_REFon the request. Refused by the incorporated hop rules themselves:v1.md§3 states that a forwarding host “shrinks thedstby exactly that element whilesrcaccumulates canonically”. The reference core enforces it and records why —core/include/libtracer/fwd_frame_view.hpp:1150-1153: this hop growssrcby its inbound mount, and “a mount NAME prepended into a fixed-stride record array is not a longer route, it is a corrupt one.” It also destroys the property §5.3 and §9.3 rest on. The origin’s fallback works because the origin still holds the canonical original; the holder of a return route holds no original of its own —srcas received is it (core/src/op_resolve_walk.hpp:686,own_tlv(req.src, flat)) — so replacing it with a bound form makes the return address reachable only in bound form, which §9.3 forbids outright.(b) Carry both: canonical
src, plus a second reverse-directionPATH_REFchild on the request. This is the only spelling that preserves the fallback, and it is the one the clause most plainly means. It contradicts “zero added request bytes” — the incorporated sentence, not merely §7.5’s heading — and it introduces a child position on aFWDrequest that no normative text describes. That is a new frame shape, which is precisely the example this RFC’s own Instrument field citesGOVERNANCE.md:54for as amendment territory.(c) The reverse list on the reply, as §7.5’s table literally says. Structurally inert, for the reason above: it never reaches the responder.
(d) The reverse element inside the operation’s payload (e.g. a child of the
SUBSCRIBERrecord a subscribe carries). Two refusals. It changes a structured type’s children, which is a wire-surface change on a type this RFC does not touch; and it is not what the clause says, because the payload belongs to the origin and a forwarder is the party that must contribute the element. A forwarder rewriting a payload it today relays verbatim also gives up the zero-copy forward the whole hop is built on.
Therefore: the reverse-direction mint is NOT implementable under the incorporated surface. Until an amendment picks a spelling — (b) is the only live candidate — a conformant host binds the forward direction only, and a return route stays canonical. Nothing regresses: the delivery direction is exactly as expensive as it is today, which is what §7.4 was already paying before this clause was written. (Resolved: amendment 1 below picks (b), 2026-08-14. This erratum stays as the record of why an amendment was the instrument, and its settled sub-questions are the amendment’s premises.)
What the clause DOES determine, recorded so an amendment need not re-derive it. Three of the four questions a reverse mint raises are already answered by text outside §7.1, and only the spelling is open:
Accumulation order is fixed by §4.1, not open. A reverse list must accumulate on the request by prepend, in lockstep with canonical
src. §4.1 requires element 0 to be the reading host’s own next-hop reference; the request travels origin-first while the reverse route runs responder-first, so append would deliver the list backwards. This mirrors the forward list, which the incorporated text also accumulates by prepend on the reply’s way back.The §7.2 mint condition has an exact analogue, and it needs no heuristic. Forward: “forwarding a canonical
dstfor which it already holds the resolution.” Reverse: forwarding a request whose arrival identity this host already holds — an identity created by this node’s own accept policy at accept time, not predicted and not measured. It is “already held” in precisely §7.2’s sense, so the clause admits it without counters, thresholds, hotness estimates or timers.The budget is already injected, and it is not a new knob. §6.4 requires the bound to come from an injected resource. For the reverse direction that resource is the accepting listener’s
max_peers: a per-session identity is allocated only on accept, and revived in place on slot reuse, so the count of bindable arrival identities is bounded bymax_peersand by nothing else — not by session churn, not by traffic. No synthetic limit appears.Erratum 1’s strip rule transfers unchanged, and it is a MUST. A hop that cannot contribute its reverse element MUST strip the whole reverse list from the request it forwards. The argument is erratum 1’s, direction-reversed and equally forced: a list that skips a hop is not a shorter route but a wrong one, and the skipped hop’s absence leaves a downstream reader dereferencing an element minted on a different host against its own vertex map, where it is perfectly valid. That is the §5.3 mis-route class. The rule is all-or-nothing over the reverse list alone; it never makes
srcpartial, because under every surviving spellingsrcstays canonical and complete.
Amendment 1 (#1223, 2026-08-14) — the reverse-direction mint gets its spelling: (b),
forwarder-contributed. Maintainer ruling on
#1223; instrument per this RFC’s own
Instrument field (a new child position on a FWD request is a new frame shape,
GOVERNANCE.md §”Errata, amendments, and the comment window”); comment window waived by default
while solo-maintained, invoked here as waived. Erratum 2’s four settled sub-questions are premises
of this amendment and are not re-derived. The normative content, in full:
The spelling is (b): canonical
src, plus a reverse-direction bound-path list as the trailing child of the forwarded request. (Re-spelled by amendment 2 below: the child is aPATH_REF_REVERSE(0x15) and is identified by that type, never by its position. This bullet originally read “a reverse-directionPATH_REF”, leaving a reader to identify it as the only trailing child; that reading is withdrawn.) The reverse child appears only on a request whoseopbit 7 (the mint request) is set, and only after the first forwarding hop. It carries §4.2’s exact grammar —opt.PLandopt.LLboth 0,lengtha multiple of 8, element count ≤ 255 — and it is last, so a positional reader of an ordinary request is untouched, mirroring the forward mint answer’s position on the reply.srcstays canonical and complete; the fallback (§9.3) is preserved, which is what ruled (b) in over (a), (c) and (d).The origin’s frame is bit-identical to today: zero added origin bytes. The origin never emits the reverse child, exactly as no peer ever sees the origin’s own element of the forward list. The
fwd/fwd-mint-requestconformance vector’s bytes stand unchanged; its prose claim narrows from “zero added request bytes” to “zero added origin bytes”.Each forwarding hop that participates PREPENDS its own element for the identity the request arrived on — its connection vertex for a point-to-point link, or the accepted session’s identity vertex for a bus session (the ADR-0044 amendment’s peer vertex; budget: the accepting listener’s
max_peers, per erratum 2’s third settled sub-question) — in lockstep with the canonical growth ofsrc, per erratum 2’s first settled sub-question (prepend, so the list runs responder-first and element 0 is always the reading host’s own reference, §4.1). A hop with no reverse child yet MAY create it (one element).The responder completes and consumes locally, mirroring the origin. The reverse list arrives one element short of the route: the hop into the responder is the one no peer can mint for it. The responder completes the list with its own reference to the connection vertex the request arrived on and consumes that element locally on every delivery (§7.4), never putting it on the wire — the exact mirror of the origin completing and locally consuming the forward list’s first element (§7.5).
A hop that cannot contribute MUST strip the whole reverse list from the request it forwards — erratum 2’s fourth settled sub-question, made normative here. Erratum 1’s argument transfers direction-reversed and equally forced: a list that skips a hop is a wrong route, the §5.3 mis-route class. All-or-nothing over the reverse list alone;
srcis never made partial.A mint is gated by the full existing check, in this direction too. A hop MUST NOT contribute a reverse element when the requesting frame could not have reached it canonically — automatic, since the element rides that very frame — and MUST NOT mint on a saturated generation (§4.4 rule 3; it strips instead). §6.2’s re-check at the dereferenced vertex applies unchanged to every delivery over the reverse list.
What this buys. §7.1’s original promise is repaid: one round trip binds both directions, and the §7.4 delivery direction becomes real rather than “exactly as expensive as it is today” — the per-delivery saving is §3.2’s
canonical_dst − (4 + 8H). It is also what #1223’s producer-side validation stands on: a producer that validates the reverse element before delivering makes a dead session’s recycled name fail the generation check instead of inheriting the stream.Compatibility, stated rather than hidden. A core that predates this RFC rejects the mint-flagged opcode outright (§7.5), so it never relays a reverse list. A core from the window between the forwarding car (2026-08-03) and this amendment masks the
opbyte and relays the trailing child verbatim without contributing — the skip-a-hop shape. That window is empty in fact:docs/implementations.md:13still reads_(none yet)_, and the reference core ships the strip rule with this amendment. A deployment that pins commits inside that window (asdocs/spec/v1.md’s draft banner instructs) must not enable the reverse mint across those hosts.
Amendment 2 (#1260, 2026-08-14) — the
reverse list gets its OWN TYPE CODE: PATH_REF_REVERSE, 0x15. Maintainer ruling on
#1260; instrument per this RFC’s own
Instrument field — a new type code is amendment territory by GOVERNANCE.md’s own example,
and an erratum “may not alter the wire surface”. Comment window waived by default while
solo-maintained, invoked here as waived (docs/implementations.md:13 still reads _(none yet)_,
so the waiver’s revert trigger has not fired). The normative content, in full:
The reverse list is a
PATH_REF_REVERSE(0x15), not aPATH_REF(0x14). Amendment 1 said “a reverse-directionPATH_REFas the trailing child”; a reader was left to identify it by POSITION — “the only trailing child of a mint-flagged request”. That positional rule is withdrawn and MUST NOT be implemented. The child is identified by its type code and by nothing else. It remains last, so a positional reader of an ordinary request is still untouched; being last is now a layout fact rather than the discriminant.Its body grammar is
0x14’s, unchanged and in full (§4.2/§4.3):opt.PLandopt.LLboth 0,lengtha multiple of 8, element count ≤ 255. The two codes differ in role, not in shape — one is an address, the other the list a mint-flagged request accumulates on its way to the responder — so every structural rule stated for0x14binds0x15identically, and a core that applies the shape check to one code alone is not conformant. Conformance vector:path-ref/reverse-len-not-multiple-of-8, the0x15twin ofpath-ref/ref-len-not-multiple-of-8.Everything else in amendment 1 stands verbatim: contributed by forwarding hops only and never by the origin; PREPEND, so the list runs responder-first; the responder completes it and consumes element 0 locally; a hop that cannot contribute MUST strip the whole list; the mint gate and the §6.2 re-check apply unchanged. The origin’s frame is still bit-identical — the
fwd/fwd-mint-requestvector’s bytes stand, again. Only the discriminant moves.What it costs: nothing. A hop reading the tail already compares each child’s type byte (
peek_trailing_mint,core/include/libtracer/fwd_frame_view.hpp), so a different constant is the same instruction — no cursor read, no body peek, no wire byte.0x15was taken adjacent to0x14deliberately: the codec’s per-TLV shape gate (“is this an element array”) stays ONE masked compare rather than a two-code disjunction, on a path every TLV of every frame runs. The rejected alternative — an in-body prefix byte — would have cost a cursor read per mint-flagged hop, a rope walk on a fragmented frame, plus a wire byte.What it buys, beyond consistency. Three things the positional rule could not give:
It un-forecloses a raw
PATH_REFpayload on a mint-flagged WRITE. Amendment 1’s rule read such a payload as the reverse list and the write silently lost it. No in-tree producer emits that shape, which is exactly why now — while nothing produces either shape — is the cheapest moment to fix it: a documentation event, not a compatibility event.It survives growth. “The only trailing child” breaks the moment any future RFC adds a second trailing child to a mint-flagged request, or forces itself to be re-spelled as an ordering constraint — a silent coupling between this decision and unrelated future work.
It is what this grammar already does. Every other element self-describes by type. Identifying one child by position is the inconsistency an implementer working from the text gets wrong once.
Type-space cost, stated so it is not re-litigated.
type_tis auint8_twith0x01–0x14assigned and0x05a retired gap; ~235 codes are free. Gaps are not reused, so this takes the next unused code rather than the retired one. There is no conservation argument to make.Deliberately NO erratum accompanies this amendment. Blessing the positional rule as shipped and then reversing it would spend a second instrument and leave the spec saying two contradictory things about the same child. Amendment 2 settles it alone; the positional rule is not recorded as correct anywhere.
Compatibility. The window is empty in fact, for amendment 1’s reason unchanged: no registered implementation exists, and the reference core ships both halves of this change in one release (v0.12.0). A core built between amendment 1 and amendment 2 emits
0x14in the trailing slot; a core after it neither emits nor accepts that spelling as a reverse list. Since amendment 1 shipped inside the same unreleased window, no deployed frame carries the old spelling.
Erratum 3 (#1260, 2026-08-14) — the
LAST hop of a reverse-list delivery egresses a canonical EMPTY PATH, not a zero-element
PATH_REF. (Errata are numbered in the order they are raised, not by the section they sit in;
erratum 4 is in §5.3.)
What the text says. §5.1 step 4 and the incorporated hop rules (
docs/reference/05-protocol-tlvs.md§0x14§routing semantics) describe a bound hop as consuming element 0 and re-heading thedstone element shorter, and the terminus as the hop with exactly one element left, which applies the operation. Read literally against a hop that consumes the final element and still has a frame to emit, that says “shrink to zero elements” — i.e. an emptyPATH_REF. Nothing in amendment 1 says otherwise: it specifies the responder’s local consumption and the residual rules for lists longer than one element, never the frame the hop consuming the FINAL element toward a session puts on the wire.What the behaviour is. That hop re-heads the
dstas a canonical emptyPATH(0x06,PL=1, length 0) — the delivery has arrived, the remaining address is empty, and the frame the session’s client sees is byte-identical to the pre-amendment canonical delivery. Shipped in #1259 (fwd_pre_t::dst_to_path,fwd_router_t::route_bound_session_delivery).Which change made them diverge. Amendment 1 itself. Before it no hop ever consumed the final element toward a party that is not a bound-form reader, so the case had no frame and needed no spelling; the one-element bound delivery arm created it.
Why this spelling, and why blessing it is free. It is what amendment 1 already promises one sentence over — “the origin’s frame is bit-identical to today”. An origin client that never speaks the bound form must never be answered in it either, or the amendment’s client-compatibility claim holds in one direction only. And an empty
PATH_REFwould be a second empty-address spelling on the wire for a case the canonical form already spells.Why it is an erratum. It alters no wire surface: 76/76 conformance vectors are bit-identical across the change (
fwd/fwd-bound-forward,fwd/fwd-bound-forwardedandfwd/fwd-mint-requestincluded), and no conforming implementation’s behaviour changes, because no other spelling was ever specified or shipped for this frame. PerGOVERNANCE.md§”Errata, amendments, and the comment window”, that is exactly an erratum: the decision was made, only the text was silent.
7.2 (a) implicit, heuristic-free¶
A hop MAY offer a vref whenever both hold:
it is forwarding a canonical
dstfor which it already holds the resolution, andits binding budget (injected, §6.4) has room.
And nothing else. No use counters, no hit thresholds, no hotness estimate, no timers. The
standing rule applies (CONTEXT.md §Resource bound; the no-synthetic-limits doctrine): a bound
comes from an injected resource. The condition above is a fact the hop is already holding, not a
prediction — which is the whole reason it is admissible.
7.3 (c) the transport carries it¶
No new exchange, no handshake, no setup frame. Binding is a rider on work that was happening anyway, which is what keeps a cold or one-shot op at exactly its present cost when the flag is not set.
7.4 Where a binding lives¶
Origin side — the user-held path object. path_t stays a value type; the binding is an
opaque slot on it that the graph/net tiers fill and validate. The layering rule holds
(dependencies point up the layers, CLAUDE.md): L1/L2 never learns what an L4 vertex index means;
it carries bytes it cannot interpret. The reuse candidate for the slot’s shape is
resolved_binding_t (core/include/libtracer/route_handle.hpp:71-86) — an allocation-free view
carrying exactly {target, target_gen, mount_gen} plus its validity flags, already the
“resolution + its staleness signal” shape this needs, and already deliberately expressing “no
resolution yet” outside the handle (route_handle.hpp:77-79) rather than as an invalid handle.
Delivery side — the subscription edge. RFC-0021
is accepted (maintainer ruling 2026-08-01, RFC-0021:12) in favour of (b), the producer’s
frame — a SUBSCRIBER’s PATH child is the delivery target resolved from the producer’s own
root — with implementation deferred past v0.7.0. That deferred implementation needs somewhere
to keep the producer-frame resolution it computes. This RFC provides that home: the edge holds
a bound path, minted at subscribe time from the very resolution RFC-0021 §4.B performs, and
re-minted on the ordinary validation failure. The two RFCs are complementary and neither blocks
the other; RFC-0021 defines what is resolved, this one defines what the answer is stored as.
7.5 The mint exchange — every byte¶
Request: zero added origin bytes (scoped by amendment 1: the sentence read “zero added bytes”
until 2026-08-14, and that is still exactly true of the origin’s frame; the reverse list rides the
forwarded legs, which the origin never emits — see §7.1 amendment 1). The bind request is a
flag in the existing op byte, not a new child and not a TLV option bit.
It cannot be a TLV
optbit: all six defined bits are assigned (opt_t,core/include/libtracer/tlv.hpp:59-65) and bits 7 and 0 are reserved-MUST-be-zero, with a set reserved bit making the frametr::frame::invalid(core/include/libtracer/tlv.hpp:66-72). There is no free option bit and this RFC does not take a reserved one.FWD’s first child isVALUE op, au8carryingREAD=0, WRITE=1, AWAIT=2, REPLY=3(RFC-0004 §B;core/include/libtracer/op_resolve.hpp:42-43) — two bits of four in use, six free. The flag takes bit 7, and this RFC adds the masking rule that makes that safe: the opcode isop & 0x3F; bits 7–6 are flags.Cost: 0 bytes. The alternative — a dedicated presence child — costs a 4-byte TLV header (plus a body byte if it carries anything) to express one bit, so it is not in the format.
Compatibility is not free, and is stated rather than hidden. Today
peek_fwd_opcasts the raw byte straight tofwd_op_t(core/include/libtracer/fwd_frame_view.hpp:628-633), so a pre-amendment peer sees an unknown opcode and rejects — a clean error, not a mis-execution, but an error. §9.3 makes the masking rule normative for every forwarder; until it is deployed, a bind request to an unknown peer costs one failed op.
The lists: 4 + 8H per direction, each riding the leg that reaches its reader (re-ledgered
by amendment 1 — the reverse row sat under “Reply” until 2026-08-14, where erratum 2 records it
could never have arrived).
direction |
rides |
added bytes |
when |
|---|---|---|---|
forward list (origin learns the route out) |
the reply, last child |
4 + 8H |
whenever a mint completes |
reverse list (responder learns the route back, §7.1 amendments 1 + 2) |
the forwarded request, as a |
4 + 8H at the last leg |
only when the mint request is set and every hop contributes (else stripped) |
Break-even, computed. Per-op saving once bound is canonical_dst − (4 + 8H) (§3.2), paid
once against a mint cost of 4 + 8H per direction — the totals below are unchanged by
amendment 1, which moves the reverse arm’s bytes to the forwarded request without repricing them:
route |
H |
saving/op |
mint (fwd only) |
break-even |
mint (both dirs) |
break-even |
|---|---|---|---|---|---|---|
direct link |
2 |
34 B |
20 B |
1st op |
40 B |
2nd op |
one forwarder |
3 |
46 B |
28 B |
1st op |
56 B |
2nd op |
two forwarders |
4 |
58 B |
36 B |
1st op |
72 B |
2nd op |
A bound path pays for its own mint on the first or second repeat, at every hop count. The
mint is cheap for the same structural reason the form is: the reply’s vref list is smaller than
the dst it replaces.
8. The bench gate — normative for acceptance¶
An implementation of this RFC is not acceptable until it answers the questions below with measurements taken under the recorded protocol. This section is a conformance requirement on the implementation train, not on peers.
8.1 It must answer #788’s five questions¶
#788 exists because #504’s obvious design lost on a shape that already ships. Its five questions transfer to this design unchanged, and the implementation must answer each for a bound path:
Where the cache lives — here, the origin path object and the subscription edge (§7.4); show that neither is router-scoped state that a fan-out re-reads.
Multi-link fan-outs —
reply-spreadis the shape that decides it. A bound path must not cost more than the scan it replaces at K=1, and must not regress at K=4.Invalidation — here, the §5 stamps. Show that nothing narrower is relied on.
Whether this is the right thing to fix at all — §2.2 already concedes the terminus-ns answer is no; the measurement must therefore land on the byte and per-hop-descent axes (§3.2–§3.4), not on the delivery leg.
Thread placement —
deliver_remoteruns on the writer thread; anything a delivery-side binding memoizes is reachable from every writing thread. Show the scoping that makes this a non-question, as #788 requires.
8.2 Under the #807 A/B protocol¶
Every latency figure MUST be taken per docs/methodology.md §”The A/B protocol”
(docs/methodology.md:400-449), which is normative for this gate:
Pin both arms identically, to the same single logical CPU on the same core class (
taskset -c 2) — the recorded confound is large: the same binary reads +47.0 % to +53.7 % slower on a compact core than a classic one (methodology.md:420-424).Interleave round-robin in one session, ≥ 10 rounds per arm, reporting medians and ranges (
methodology.md:434-437).Discard the first execution — a cold first point reads ~313 ns against a 228 ns steady state, +37 % (
methodology.md:438-440).Prefer same-directory A/B (
methodology.md:441-442).Where the expected effect is below the leg’s noise floor, do not reach for a stopwatch — use object-file
cmpagainst the baseline tree (methodology.md:443-447).
Cross-worktree build layout is not a valid explanation for a difference: it was measured and
refuted, byte-identical output at two paths (methodology.md:404-418).
8.3 The must-not-regress arm¶
The four-link reply-spread fan-out is a mandatory arm. It is the exact shape that killed
#504’s memo (140–311 ns/write). A measured latency regression on any shipped shape rejects the
implementation, per the standing ruling that a latency regression is never an acceptable trade.
8.4 What must be measured, not argued¶
The per-hop descent saving (§3.4) — discharged: a multi-hop chain bench with both spellings, H = 2/4/8, identical traffic semantics per round, read as a slope rather than a point so the fixed origin and terminus legs cancel.
bench_forward_demuxwas the named instrument and is the wrong one for this question: it times a single hop against registry size, where a slope over hop COUNT is what a per-hop claim is about.The terminus deref against the canonical resolve — expected below the noise floor, so §8.2 rule 5 applies (object-file
cmp) before any stopwatch.rv32 flash/RAM delta, including §6.4’s index vector at a realistic vertex count. The standing census rule applies: no “beat” is banked without it.
9. Proposed change¶
Spec edits land after acceptance, in a follow-up PR; the RFC’s own PR added only the RFC
document and the two glossary entries (§11). Acceptance has since happened, and the edits land
car by car — a spec bullet is incorporated in the same train as the code that honours it, so no
normative text ever cites a clause with no implementation behind it. The codec car landed §9.1’s
0x14 registry bullet and its v1.md §3 incorporation, and those two alone. The routing car
landed the rest: the FWD bullet (the op & 0x3F masking rule and the bind-request flag, plus
dst/src MAY be a PATH_REF), the 05 §routing-semantics text that v1.md §3 now
incorporates for §5-§7, the 03-addressing.md two-forms section and the
13-network-formation.md diameter row — each in the same train as the code that honours it,
which is what makes their clauses observable. The forwarding car (2026-08-03) closed the
set: 05 §0x14 §routing-semantics replaced its terminus-only carve-out with the hop rules
(consume element 0, egress through the dereferenced connection vertex, shrink the dst by one
element while src accumulates canonically, and §7.1 erratum 1’s strip-or-contribute rule),
v1.md §3 incorporates them, and the two forwarded-frame vectors of §9.4 publish the bytes.
Only §9.2’s NACK spelling is still open.
9.1 New normative text¶
docs/reference/05-protocol-tlvs.md— a new## 0x14 — PATH_REFsection carrying §4’s grammar, the byte ledger, thePL=0/LL=0MUSTs, the ≤ 255-element bound, and thelength % 8 != 0⇒tr::frame::invalidrule;:901’s “Unassigned:0x14–0x1F” becomes0x15–0x1F;:16’s first-block census line gains the new assignment.docs/reference/05-protocol-tlvs.md§reserved-range —FWD’sdst(andsrc) MAY be aPATH_REF; theopbyte gains the& 0x3Fmasking rule and the bind-request flag.docs/spec/v1.md§3 — incorporate thePATH_REFconstraints alongside the existing PATH incorporation bullet (v1.md:62-64).docs/reference/03-addressing.md— the two path forms, and the rule that canonical is the mint key and the fallback.docs/reference/13-network-formation.md— the diameter statement gains the bound-path row (H ≤ 255 normative, ≤ 69 reachable today, §4.3).RFC-0004§B — amended: theopbyte’s flag bits;dst/srcmay bePATH_REF.
9.2 Deferred to implementation review¶
The NACK spelling (§5.3): extend HANDLE_NACK with a hop-index child, or take a sibling code from
0x15–0x1F. Both are additive; the vocabulary argument (§2.1) favours the sibling.
Not deferred here: the reverse-direction spelling — and no longer open. §7.1’s symmetry option
was not an implementation-review question, and erratum 2 is why — every spelling of it
contradicted an already-incorporated normative sentence (“zero added request bytes”, or src
accumulating canonically), so it needed an amendment, not a choice made in review. Amendment 1
(§7.1) made that amendment on 2026-08-14: spelling (b), forwarder-contributed, the origin’s frame
bit-identical. What remains for implementation review is only the ordinary kind: the request-side
mirror of the reply’s mint rebuild, and the producer-side validation
(#1223 steps 3–4).
9.3 Conformance obligations on a peer¶
Canonical support stays mandatory.
PATH_REFis optional to emit and optional to accept; a peer that does not accept it MUST answer such a frame per the forward-compatibility rules ofdocs/reference/01-data-format.md§handling unknown type codes, and the origin MUST fall back to canonical. No address is reachable only in bound form — guaranteed by construction, since every bound path is minted from a canonical one (§7).A forwarder MUST mask the
opbyte (op & 0x3F) rather than switching on the raw value, so an unrecognised flag degrades to the plain opcode instead of an unknown-opcode reject.A host MUST NOT wrap a generation (§4.4 rule 3); on saturation it refuses to mint.
A failed validation MUST drop, never repair (§5.3).
Every bound-form op MUST re-check
acl_allowsat the deref’d vertex (§6.2).
9.4 Conformance vectors¶
path-ref/ref-2host,path-ref/ref-3host— round-trip encodings pinning the ledger of §4.2 byte for byte.path-ref/ref-len-not-multiple-of-8— reject.path-ref/ref-pl-set—opt.PL=1on aPATH_REF⇒ reject.path-ref/ref-ll-set—opt.LL=1on aPATH_REF⇒ reject. §4.2’s second envelope MUST is a separate clause fromPL, so it needs its own vector: a core that drops it passes every other vector in the category.path-ref/ref-256-elements— over the normative cap ⇒ reject.path-ref/ref-empty— a zero-element body (length = 0) round-trips. The other end of §4.3’s range: the count bound is an upper one, and a route with no hops is the router’s to refuse (§5), not the codec’s, so a core that rejects it is as wrong as one that accepts 256.fwd/fwd-bound-forwardandfwd/fwd-bound-forwarded— one bound hop apart: a two-element residual as it arrives, and the one-element residual with a grownsrcthat leaves. The harness routes nothing, so the pair’s behavioural claim is bound bycore/tests/bound_forward_test.cpp, which asserts both byte-exact against what the router emits.acl/bound-vs-canonical-allowandacl/bound-vs-canonical-deny— §6.3’s mandated pair, asserting byte-identical outcomes between the two spellings.Zero existing vectors change:
PATHis untouched, and every existing frame is canonical.
9.5 Code, after acceptance¶
core/include/libtracer/tlv.hpp (type_t::PATH_REF = 0x14), the dense index vector (§6.4), the
element codec, the path_t binding slot (§7.4), the forwarder’s bound-form branch beside
resolve_mount_at, and the NACK. Bindings (bindings/rust, bindings/typescript) and the
Wireshark dissector (tools/wireshark/libtracer.lua) follow. Public-header changes go in
core/CHANGELOG.md.
10. Interactions¶
RFC-0017 (element addressing,
[n]) — orthogonal. RFC-0017 indexes the value plane inside a vertex;PATH_REFaddresses the graph plane between vertices.PATHis untouched, so RFC-0017’s[n]grammar is unaffected; a bound path reaching a vertex composes with aFIELDselector exactly as a canonical one does.RFC-0018 (packed segments) — orthogonal; both can land. RFC-0018 makes the canonical form cheaper (a 5-segment
dst49 B → 34 B, RFC-0018:32-33); this RFC adds a second form. They compete only in the sense that a cheaper canonical form narrows the bound form’s margin — from ~2.6× to ~1.8× on §3.2’s shapes — and neither changes what the other does. Both land independently; whichever lands second re-runs §3.2’s table.RFC-0004 §E.1 — stays, as the per-link delivery-compaction tier. §2.1 is the comparison; the vocabulary rule is normative.
RFC-0021 — accepted, implementation deferred; this RFC is the home its deferred producer-frame resolution needs (§7.4).
#419 — settled (ruling (a)): the vector encodes one three-segment mount plus residual and is now named
fwd/fwd-routed-mount-residual, with a genuine two-hop companion atfwd/fwd-routed-two-mount. The adjudication had turned on the fact that reading the bytes cannot distinguish the two models, because both produce the samedststring. APATH_REFmakes hop structure explicit on the wire — H elements is H hosts, with no reading required — so the bound form never reproduces that ambiguity class.RFC-0019 / RFC-0023 byte bounds —
PATH_REFis bounded by hop count, not segment count, and derives its own bound in §4.3 (normative ≤ 255 elements / 2040 B; reachable ≤ 69 today, ≤ 171 packed).kMaxSegmentsandkMaxPathBytes(core/include/libtracer/path.hpp:35,:33) are untouched and continue to govern the canonical form alone.
11. CONTEXT.md¶
Two entries are added to §Graph, addressing & API in this PR, in the existing format:
Bound path — the second normative path form; node-scoped vref elements; canonical is the mint key and the fallback; a failed validation drops and re-mints, never mis-routes.
Vertex ref (vref) — the 8-byte element; index + generation; node-scoped, meaningless elsewhere; an address, never a capability.
Both carry an _Avoid_ line, and the load-bearing one is the vocabulary rule of §2.1:
“label” stays RFC-0004 §E.1’s per-link u16. Calling a vref a label is the confusion this
whole document is arranged to prevent.
12. Rejected alternatives¶
Element shapes are in §4.5. Design-level rejections:
Extend §E.1’s label to client-originated binding (#504’s original shape). Rejected on state: it puts a table on every hop, which is the property §E.1 itself scopes to compact flows only (“a constrained ws node forwarding 50 cold reads holds zero label state”, RFC-0004:187), and it needs an advertise round before the first op. The bound path holds nothing at any hop and mints inside work already happening.
A per-hop route cache keyed on canonical bytes. Rejected: it is the ADR-0062 reverse-index shape twice refused — “it moves work onto the control plane’s lock to serve the minority flow, and it is a SECOND invalidation mechanism beside one that works” (
core/include/libtracer/child_registry.hpp:281-283).A negotiated capability for bound-path support. Rejected for minimalism: v1 has no capability negotiation and deliberately so (
CONTEXT.md§Capability negotiation, ADR-0013). The fallback in §9.3 is a closed-form local decision needing no negotiation — try bound, take the NACK or the unknown-type answer, use canonical.Making
PATHitself carry either form (a mode bit on0x06). Rejected: it would make the canonical-bytes property conditional, and that property is whatpath_lookup_key(core/src/op_resolve_walk.hpp:660-669) and every peer, cache and router keyed on PATH bytes depend on — the injectivity failureop_resolve_walk.hpp:649-652records (two byte-different PATHs addressing one vertex) is exactly what a mode bit reintroduces. A separate type code costs one codepoint out of eleven free and keeps0x06meaning one thing.
13. What is UNMEASURED¶
Labelled per the standing rule — a quantitative claim names its instrument or is labelled.
Every byte figure in §3.2, §3.3, §4.2 and §7.5 is arithmetic over the shipped encoding rule (
reference/05:377) and the recorded mount-run shapes (RFC-0023:185-188). Not a capture. A routed capture at H ≥ 3 does not exist;bench_hop_chainremains recorded as confounded and must not be cited.The per-hop descent saving (§3.4) is MEASURED — 261.7 ns/hop bound against 304.7 canonical, as the 2→8-hop slope under §8.2’s protocol. It is no longer a structural argument. What remains labelled is its GENERALITY: one host, one registry width, one link class.
The terminus deref cost is UNMEASURED, and expected to sit below the noise floor — §8.2 rule 5 applies before any stopwatch.
rv32 flash/RAM delta is UNMEASURED, including §6.4’s index vector. The size census is the instrument; no “beat” is banked without it.
The
u32generation lifetime figures in §4.4 are arithmetic over an assumed retire rate. No retire-rate measurement from a deployment exists. This is why the saturation rule, not the width, carries the safety.
14. What would falsify this RFC¶
A measured latency regression on any shipped shape — especially the
reply-spreadfour-link arm (§8.3). This design dies the same death #504’s memo did, and by the same rule.The per-hop descent saving measures to nothing. If a bound hop is not measurably cheaper than
resolve_mount_atat realistic registry widths, §3.4 collapses and the case reduces to bytes alone — at which point §E.1’s 10 B flat beats 8 B/host from H ≥ 2, and this RFC should be withdrawn in favour of extending §E.1. Tested and not met (§3.4): 261.7 vs 304.7 ns/hop. The clause stays live for a second host or a wider registry — one instrument is not a general result — and it came within one build of firing, which is the record §3.4 keeps.The index vector’s RAM cost is not affordable on rv32 at a realistic vertex count (§6.4). 4 B/vertex is small, but ADR-0067’s census banked “libtracer’s own static RAM is approximately zero”, and this is not zero.
A generation-wrap path survives the saturation rule. If any reachable sequence lets a stale vref validate, the guard has failed and the element shape must be re-derived — §4.4’s entire argument rests on saturation closing that class.
The maintainer rules that two normative path forms is one too many. Then the design is sound but unwanted, and the answer is to extend §E.1 rather than to add a form.
15. Discussion¶
Per GOVERNANCE.md §”Errata, amendments, and the comment window”,
this is an amendment: RFC plus maintainer approval, comment window waived by default while
solo-maintained (verified in the header; the waiver reverts the moment docs/implementations.md
gains a registered implementer). Scope is v-NEXT — this document explicitly does not gate
v0.7.0, and the spec edits of §9 land in their own PR after acceptance.