Asymmetric / Asynchronous Routing Redesign (greenfield)
Status: design proposal, greenfield. Breaking wire-format changes are in
scope. This document proposes a replacement for the MeshCore-style path block
(docs/PROTOCOL.md §Path block, docs/ROUTING.md §uplink) and for the
“reverse the recorded path” return story. It is normative in voice but not
yet shipped: nothing here is frozen by tests/test_packet.cpp until the
wire-change checklist (/lrc-wire-change) is run against it.
This doc owns the routing/RF half of the redesign. A separate routing THREAT-MODEL doc owns authentication. Every place this design depends on a field being signed/authenticated is labelled [SEC-Hn] and collected in §10 (SECURITY-SENSITIVE FIELDS) for the consolidator to reconcile. Where this doc and the threat-model doc disagree, the [SEC-Hn] tags are the join key.
Companion reading already in-repo: docs/ROUTING.md (cellular model),
docs/RADIO.md (lanes/presets/SNR ladder), docs/PROTOCOL.md (current wire),
docs/research/COMPARISON_THESIS.md §3 (Meshtastic/MeshCore/Reticulum routing).
1. The problem: paths are asymmetric, asynchronous, and hub-terminated
Section titled “1. The problem: paths are asymmetric, asynchronous, and hub-terminated”chIRpChat today borrows MeshCore’s path recording (docs/ROUTING.md §uplink): a
discovery packet starts with an empty path block, each relay appends one UID
byte, and the reply (WELCOME/ACK) carries those bytes reversed so the
return retraces the request. chIRpChat already softens this — “asymmetry is embraced;
the two directions maintain independent paths” (docs/ROUTING.md) — but the
mechanism by which the return path is obtained is still “reverse what the
forward leg recorded.” That mechanism has three failure modes on real RF:
-
Asymmetric hop count. A high-powered, well-sited chIRpChat router may be 3 weak hops away on the uplink but 1 hop on the downlink. Recording the 3-hop uplink and reversing it forces the downlink through 3 hops it does not need, tripling airtime and collision surface for the cheap direction. Reverse-the-path treats each to/fro hop as symmetric; the channel is not.
-
Asynchrony. The reply may be emitted seconds to days after the request (mailboxed DM, batched downlink,
CHANSYNCbackfill, make-before-break roam). By the time it leaves, the recorded relays may be asleep, retuned to another lane, moved, or dead. A path is a snapshot; an async reply needs a path computed at send time, and may legitimately need more or different hops than the request used. -
The hub boundary breaks the chain abstraction entirely. Once a packet reaches a high-powered router, that router can reach ~300 nodes and other routers in a single broadcast (
docs/ROUTING.md§Anti-congestion: “N members cost 1 TX, not N”). Past that node, routing is not a chain — it is a broadcast domain plus a scheduled-downlink calendar (docs/RADIO.md§lane plan, listen-after-talk). Appending hop ids past the hub is meaningless: there is no single “next hop,” there are 300 of them, selected by which clients that hub sequences.
Conclusion. Source-recorded reverse paths are the wrong primitive for the edge (asymmetric, async) and a category error at the hub (broadcast, not chain). The redesign splits routing into two regimes with a clean boundary, and replaces “reverse the path” with an independently established, independently maintained return descriptor that the router (not the recorded relay chain) is responsible for.
2. Model overview
Section titled “2. Model overview”Two regimes, one boundary node (the router):
client ──edge mesh──▶ ROUTER ══backbone/broadcast domain══▶ ROUTER ──edge mesh──▶ client (opportunistic (chain ENDS here: (independent SNR/progress- broadcast downlink + return mesh, ranked relaying, scheduled lane calendar may differ from suppression) + TCP/RF federation) forward edge)-
Edge regime (client ↔ its router, through 0..N relays). Forward delivery is opportunistic progress-ranked relaying with an SNR/progress contention window and explicit suppression (§3), replacing pinned source routes for the discovery and first-contact case. A relay does not need to be “on the recorded path”; it relays if it makes the packet closer to a router and no better-placed relay beat it to it.
-
Hub regime (router outward). The chain terminates at the first router. The router is the broadcast domain head and the federation ingress. Downlink is a router broadcast on its published lane calendar (
docs/RADIO.md§lane plan) — exactly what chIRpChat already does for channel fan-out — never a reversed relay chain (§5). -
Return is a router responsibility, not a relay-chain artifact (§4). The router that terminates a client’s uplink mesh holds a small Return Descriptor (RD) telling it how to get back to that client: “broadcast on anchor,” or “via relay R then broadcast,” or “the client is promoted to lane L, schedule it there.” The RD is built from observed receptions and refreshed independently of any single request, so an async reply uses a current return route, possibly with more/different hops than the request took.
The forward edge and the return descriptor are decoupled objects with independent lifetimes. That is the core departure from reverse-the-path.
3. Forward edge: opportunistic progress-ranked relaying + suppression
Section titled “3. Forward edge: opportunistic progress-ranked relaying + suppression”This replaces “a relay forwards only when path[hops_taken] matches its UID
byte” (docs/PROTOCOL.md §Path block) for discovery/uplink. Pinned exact
source routes are removed as a forwarding gate; pinning survives only as an
optional hint (§6, the HINT field) that biases the contention window, not
as a hard match.
3.1 Progress metric
Section titled “3.1 Progress metric”A relay forwards an uplink frame only if it improves router-progress.
Each node maintains, per heard router, a router-distance estimate
rdist(rtr) = the best (lowest) hop-count-to-a-router it has observed, learned
from BEACONs and from relayed frames (every uplink frame in flight carries the
emitter’s current rdist, see §6 RPROG). A relay R that hears an uplink
frame stamped with sender progress rprog_in forwards only if:
rdist_R < rprog_in // R is strictly closer to a router than the senderThis is the chIRpChat analogue of MeshCore’s “am I a useful hop” check, but keyed on
router-progress rather than membership in a recorded path. A frame that
reaches a node with rdist == 0 (a router, or a node co-located with one) has
arrived: that node ingests it and the edge regime ends. Progress, not a
recorded chain, decides who relays — which is what makes the forward edge
robust to async (no stale recorded relay) and to asymmetry (the cheap direction
is found by progress, not inherited from the expensive one).
3.2 The contention window (delay formula)
Section titled “3.2 The contention window (delay formula)”When multiple relays could forward (all closer to a router), we want the one that heard the packet best / makes the most forward progress to go first, and the rest to suppress. We adapt Meshtastic’s SNR-weighted contention window (CSMA/CA over CAD slots) [1][2] but add a progress term chIRpChat can afford because chIRpChat knows router-distance.
Define a slot = one CAD cycle at the current preset (Meshtastic’s unit;
docs/RADIO.md §Air discipline already does CAD before every TX). For a relay
R considering forwarding frame F received at snr with sender progress
rprog_in:
prog_gain = rprog_in - rdist_R // ≥1 by §3.1; how much closer R issnr_rung = preset rung implied by snr // index into kPresets via probe_snr_x10CWsize = clamp( CW_BASE - k_snr * snr_rung // better local SNR ⇒ EARLIER - k_prog * (prog_gain-1)// more progress ⇒ EARLIER , CW_MIN, CW_MAX )
delay(F,R) = slot_ms * ( BACKSTOP + uniform_int(0, 2^CWsize) ) // jittered, like [1]Defaults (greenfield, tunable; bounded so the math fits uint8_t shift):
CW_MIN=2, CW_MAX=7 (Meshtastic’s published range [1]), CW_BASE=6,
k_snr=1, k_prog=1, BACKSTOP=2, slot_ms = one CAD cycle for the preset.
prog_gain is clamped to [1,4] before use (a single hub jump caps progress
benefit; 4-bit hop space, §6).
Host implementation seam: RoutingContentionInput carries only
snr_local_x10, rprog_in, and rdist_self; routing_contention_window()
computes the CW size and bounded delay range, and
routing_contention_delay_ms() applies a caller-supplied jitter slot. The
caller owns the CAD-slot duration and randomness, so the core policy stays
portable and Rule D’s “local SNR only” invariant is visible in the API.
Two deliberate differences from Meshtastic:
- SNR term sign is chIRpChat-specific:
kPresetsis ordered from slower/weaker links to faster/stronger links, so a higher locally measured SNR rung subtracts fromCWsizeand gets the earlier window. This deliberately favors the relay that heard the packet best, instead of Meshtastic’s outward-flood bias [2]. - Progress term is new: among nodes with similar SNR, the one that gets the frame more hops closer to a router wins the window. This is the “most forward progress responds quickly” rule the maintainer asked for, expressed in chIRpChat’s router-distance terms rather than geographic progress.
This window envelopes — and replaces for the discovery/uplink case — today’s
flat relay jitter U(0, airtime×2.5) (docs/RADIO.md §Air discipline). That
flat jitter stays as the fallback for frames with no progress signal (foreign
blind-relay, §9).
3.3 Suppression rule (the suppress trigger)
Section titled “3.3 Suppression rule (the suppress trigger)”A relay R that has scheduled a forward of F at delay(F,R) cancels it iff,
before its timer fires, R hears any frame R can recognize as the same logical
frame F advanced past R’s contribution:
SUPPRESS R's pending TX of F ⇔ R hears F' with dedup_key(F') == dedup_key(F) // same (SRC,MSGID) — §Dedup, unchanged AND rprog(F') ≤ rdist_R // F' is already at least as close as R would make itdedup_key is the existing SipHash(SRC‖MSGID) (docs/PROTOCOL.md §Dedup) —
unchanged, and already the right identity for “same logical frame.” The
second clause is the novelty: R suppresses only if the overheard copy is
already as good as R’s relay would be. If R would still make strictly more
progress than the copy it overheard, R does not suppress — it relays,
because it is genuinely the better next hop (this is the chIRpChat analogue of
Meshtastic’s ROUTER/REPEATER “rebroadcast even if you hear another” exception
[1], but earned by measured progress rather than a static role).
Router-as-fast-responder. A node with rdist==0 (router/co-located) that
hears an uplink for itself emits an immediate, tiny PROGRESS-ACK beacon-let
(§6 RACK, 0-payload, PRIO) at delay≈0. Every edge relay that hears the
RACK and was about to relay the same dedup_key suppresses (clause-2 satisfied
trivially: the router is at rdist 0). This collapses the whole edge mesh the
instant the frame reaches a router — the single biggest airtime win, and the
direct analogue of Meshtastic’s “you hear it relayed, you stop.” The router got
the frame; nobody else needs to relay it.
3.4 Why this beats reverse-the-path on the forward leg
Section titled “3.4 Why this beats reverse-the-path on the forward leg”- No relay needs to have been “recorded” to be useful; any node that improves progress can step in, so async and mobility don’t strand the route.
- The best-SNR / most-progress node wins each window and the rest go quiet, so redundant relays cost one suppression-listen, not a transmission.
- The frame self-describes its progress (
RPROG), so suppression is a local decision with no shared state and no per-flow setup.
4. Return: the Return Descriptor (independent, may add hops)
Section titled “4. Return: the Return Descriptor (independent, may add hops)”The return story is not “reverse §3.” It is: the router that terminated the uplink owns a Return Descriptor (RD) per client UID, built from passively observed receptions and refreshed on its own schedule. An async reply is routed by re-running forward delivery from the router toward the client using the current RD, which can name more or different hops than the uplink used.
4.1 What an RD is
Section titled “4.1 What an RD is”ReturnDescriptor (per client UID, held at the router that registered/last-heard it): uid8 client u8 mode // 0 BROADCAST, 1 RELAY_VIA, 2 LANE_SCHED, 3 UNKNOWN u8 via_uid1 // mode RELAY_VIA: first-byte UID hint of the best return relay u8 lane_id // mode LANE_SCHED: client is promoted; schedule on this lane u8 ttl_quart // confidence/freshness, quarter-life counter (decays) i16 last_snr_x10// best downlink-direction SNR observed toward this client u32 refreshed_msmode is chosen by the router from what it observes, independent of the
uplink path:
- BROADCAST — the router heard the client directly with margin, or the client is a direct (0-hop) downlink. Reply is a normal router broadcast on the client’s lane (anchor or promoted). This is the asymmetry win: a 3-hop uplink whose router is loud collapses to a 0-hop downlink. Default mode.
- RELAY_VIA — the router cannot reach the client directly, but a relay
consistently delivers the router’s frames to it. The relay is admitted to
via_uid1only through the observed-delivery gate (§4.2): the router must have measured a delivery attributable to that relay (relay-sourced delivery confirmation, or the router overhearing its downlink advanced by that relay) — never from aTRAIL[]byte, which is an unauthenticated candidate hint at most. The return goes through that relay, which may be a different relay than any on the uplink and may add a hop the uplink never used. This is the “reply needs MORE/different hops” case made first-class. RELAY_VIA is never the first-contact mode (§4.2 first-contact RD rule). - LANE_SCHED — the client is lane-promoted (
docs/RADIO.md§Lane promotion,LANEGRANT); the router schedules the reply on the granted lane in the client’s listen window. Reuses the existing promotion machinery unchanged. - UNKNOWN — no RD yet (cold reply to a never-heard client): the router emits the reply as a §3 forward-edge frame toward the client UID with a fresh discovery progress field, and lets opportunistic relaying find it. The first successful delivery teaches the RD.
4.2 How an RD is established and maintained (independently)
Section titled “4.2 How an RD is established and maintained (independently)”The governing rule for everything below is the observed-delivery gate ([SEC-H5], §10), stated once and applied everywhere TRAIL or a candidate UID appears:
Observed-delivery gate. A relay is admitted to the RD (as
via_uid1), to the return-candidate ring (§7.2), or to a RELAY_VIA selection only after the router has observed that relay actually deliver a frame — i.e. the router measured the reception of a forwarded frame attributable to that relay. A relay is never admitted fromERB.TRAIL[]presence.TRAIL[]is a diagnostic / unverified candidate hint only; it may propose a candidate and, among candidates that have already passed the gate, it may only influence their ordering — it can never, by itself, pin an RD, select a RELAY_VIA, admit a return-candidate, or prune another node’s hop.
- Established on first contact: the uplink frame that reaches the router
carries the client’s own
rdist/SNR-to-router and a relay-progress trail (§6TRAIL/rprog). The router seedsmodeonly from router-measured reception — its own SNR margin to the client — never from the recorded hops. First-contact RD rule (Issue 3): before any observed delivery, a client’s RD MUST be BROADCAST (the router heard the client directly with margin — a router-measured fact, not a wire claim) or UNKNOWN (re-discover via the §3 forward edge). It MUST NOT be RELAY_VIA seeded from TRAIL: a TRAIL byte is unauthenticated forward data and pinning a return through it would be a return route chosen from attacker-touchable input.via_uid1is populated only after an observed successful delivery (below) names a real relay. On first contactTRAIL[]serves only to propose return candidates for later confirmation; it pins nothing. - Maintained out-of-band: every later uplink (chat, ACK piggyback, keepalive)
refreshes
last_snr_x10,refreshed_ms, and may flipmode(e.g. client walked behind a hill → BROADCAST degrades → RELAY_VIA) — but a flip to RELAY_VIA still requires the observed-delivery gate: the router promotes a candidate tovia_uid1only once it has measured a delivery through that relay, never on TRAIL refresh alone.ttl_quartdecays on a timer; an RD that goes stale falls back to UNKNOWN and the next reply re-discovers. The RD’s lifetime is independent of any request’s lifetime, which is exactly what asynchrony requires. - What counts as an observed successful delivery. The router measures, on its own receiver, a frame attributable to a relay that advances one of the router’s own outbound frames toward the client (e.g. a relay-sourced delivery confirmation, or the router overhearing the relay forward the router’s downlink). The attribution is from the router’s measurement, not from a TRAIL byte. A candidate that never produces such a measurement is never promoted to RELAY_VIA and ages out of the ring (§7.2).
- Return capacity is retained while the forward edge is pruned (§7). Pruning
a relay out of the forward fast path does not delete it from the
return-candidate ring: a relay dropped forward because a closer/faster preset
skipped it (
prog_gaincollapsed) remains a candidate for the slow return — but, like any candidate, it is eligible for RELAY_VIA only once it has passed the observed-delivery gate (a pruned-then-poisoned relay cannot launder itself into a trusted return from TRAIL alone).
4.3 Boundary with federation (the far side)
Section titled “4.3 Boundary with federation (the far side)”For cross-router DMs the RD lives at the recipient’s router, reached over the
backbone (docs/ROUTING.md §backbone, TCP > fastLoRa > anchor). The sender’s
router does not need the recipient’s RD; it hands the frame to the backbone and
the recipient’s router applies its RD. This is already how chIRpChat DMs flow
(docs/ROUTING.md §DM delivery); the redesign only changes the last-mile
return from “reverse path” to “recipient router’s RD,” and leaves
FLAGS.VIA_TCP egress rules (docs/PROTOCOL.md) untouched.
5. The hub boundary: chain → broadcast + schedule
Section titled “5. The hub boundary: chain → broadcast + schedule”At the first router the chain abstraction ends. Concretely:
- An uplink frame that reaches
rdist==0is ingested, never re-appended-to. No “relay id past the hub.” The router is the broadcast-domain head. - Downlink toward edge clients is a router broadcast on the published lane
calendar (
docs/RADIO.md§lane plan, listen-after-talk;LANEPROBEopens the floor). For channels this is already O(1)/cell fan-out. For a unicast reply the router still broadcasts on the client’s lane window (RD mode BROADCAST) or schedules via RD mode RELAY_VIA / LANE_SCHED — the router’s calendar, not a reversed chain, decides timing. - Router→router is the existing backbone broadcast domain (federation
gossip + best-link selection,
docs/ROUTING.md§backbone). Past the first hub, “300 nodes + other routers in one broadcast” is the topology; routing there is link-class preference + RTRSYNC, which the redesign keeps verbatim.
So the wire never carries hop ids past a router. The path/return structures in §6 are edge-only; they are absent on backbone frames.
6. Wire format (greenfield routing block)
Section titled “6. Wire format (greenfield routing block)”Replace the v1 FLAGS.PATH 1-byte-per-hop block (docs/PROTOCOL.md §Path
block) with a fixed-shape Edge Routing Block (ERB), present iff a new
FLAGS.ERB bit is set. The common header (VT/FLAGS/TH/PLEN/MSGID),
addressing groups A (channel), B (unicast), C (control), dedup, tag/sig
trailers, and EXT_ADDR semantics are unchanged — the ERB slots into the
exact position the old path block occupied (after addressing, before frag), so
layout() in core/src/packet.cpp changes only that one section.
6.1 FLAGS reassignment
Section titled “6.1 FLAGS reassignment”FLAGS.PATH (bit 3) is retired and reused as FLAGS.ERB. kMutableMask
(core/include/lrc/packet.h) changes from ViaTcp|Path to ViaTcp|ERB: the
ERB is mutable in flight (relays rewrite progress), so it stays excluded from
the canonical image exactly as the path block was (§10 [SEC-H1]). This is safe
precisely because no ERB field feeds a trust decision: rprog/HINT are
non-gating optimizations and TRAIL[] is a diagnostic / unverified candidate
hint whose only consumers (RD seeding, return-candidate ring) admit nothing
without the §4.2 observed-delivery gate. No security-relevant state lives in the
masked ERB, so the canonical image needs no re-audit and no per-hop MAC is added
(§10 [SEC-H5]). All other flag bits keep their meaning.
6.2 Edge Routing Block layout
Section titled “6.2 Edge Routing Block layout”Present when FLAGS.ERB. Fixed 4-byte head + optional bounded trail:
| Off | Size | Field | Meaning |
|---|---|---|---|
| 0 | 1 | ERB_VER |
bits 7..6 ERB version (00); bits 5..4 mode; bits 3..0 rprog (router-distance the emitter claims, 0..15) |
| 1 | 1 | HINT |
first-byte UID of the preferred next relay (0 = none); a soft contention-window bias, never a hard gate (§3) |
| 2 | 1 | RACK_RTR1 |
first-byte UID of the router this frame is converging on (0 until known); lets relays recognise PROGRESS-ACKs |
| 3 | 1 | TRAIL_LEN |
bits 7..4 reserved (0); bits 3..0 = number of breadcrumb bytes following (0..15, bounded by 4-bit hop space) |
| 4 | TRAIL_LEN | TRAIL[] |
breadcrumb first-byte UIDs, append-only, diagnostic / unverified candidate hint only — feeds NO trust decision (not a forwarding gate, not an RD pin; admits nothing without the §4.2 observed-delivery gate) |
mode (bits 5..4 of byte 0): 00 UPLINK-DISCOVERY, 01 UPLINK-HINTED,
10 RETURN, 11 RACK (PROGRESS-ACK; TRAIL_LEN=0, payload empty, PRIO).
Semantics:
rprogis the progress field of §3. A relay that forwards rewritesrprog := rdist_R(its own, lower, router-distance) so the next hop’s suppression math (§3.3) is correct. This is the only mutated ERB field on a normal relay and is why the ERB is inkMutableMask.HINTcarries a pin learned from a prior successful exchange (replaces the old “pinned path” pin, but as one byte and as a bias only): a relay whose UID first-byte matchesHINTsubtracts a bonus fromCWsize(relays earlier), so a known-good route is preferred without being required. If the hinted relay is asleep/moved, progress-ranked relaying still finds a path — fixing the “stale pin strands an async reply” failure (§1.2).TRAIL[]is append-only breadcrumbs (like the old path bytes) but is never consulted for forwarding and, per the observed-delivery gate (§4.2, [SEC-H5]), is a diagnostic / unverified candidate hint ONLY — it feeds no trust decision. It exists so (a) the terminating router can propose return candidates for the RD (§4.2) and the return-candidate ring (§7), which are then admitted to RELAY_VIA only by observed delivery, never by the TRAIL byte itself, and (b) loop-dampening: a relay drops a frame whoseTRAILalready contains its own UID byte (cheap, ambiguous-by-design like the old block — ambiguity costs an occasional dup, absorbed by dedup). Because TRAIL now feeds no trust decision, a tampered TRAIL can at worst mis-order already delivery-validated candidates or waste one diagnostic field — a bounded airtime/diagnostic cost, which is exactly why it is safe to leave unsigned and inkMutableMask(§6.1, [SEC-H5]).
6.3 Sizes
Section titled “6.3 Sizes”- Old path block:
1 + path_len(up to 16 B). New ERB:4 + TRAIL_LEN(4..19 B), but the common case isTRAIL_LENsmall. A 2-hop uplink ERB is 6 B vs the old 3 B — +3 B. The cost buys progress-ranked relaying and an asymmetric return; on the downlink (BROADCAST mode)TRAIL_LEN=0, so the cheap direction is 4 B flat regardless of uplink length, which the old reverse-path could never do (it paid the full uplink length on return). - All sizes bounded by the 4-bit fields → no unbounded growth, fits the
uint8_tarithmetic inlayout().
6.4 New control frame types
Section titled “6.4 New control frame types”RACK(PROGRESS-ACK): group C,SRC= router UID, payload empty,PRIO,FLAGS.ERBwithmode=RACKandRACK_RTR1set. Never acked, never relayed past 1 hop (TTL=1). It is the §3.3 suppression collapse signal.LANEGRANT(existing,docs/PROTOCOL.md§Lane promotion) is reused verbatim for RD mode LANE_SCHED — no change.
6.5 Interaction with addressing groups & lanes
Section titled “6.5 Interaction with addressing groups & lanes”- Group A (channel) frames carry an ERB only on the uplink leg
(client→router); the sequenced downlink broadcast carries no ERB
(
TRAIL_LEN=0, mode irrelevant) because fan-out is a router broadcast (§5). - Group B (unicast) DM uplink carries an ERB (UPLINK-*); the reply carries an ERB only in RD mode RETURN/RACK as needed.
- Group C (control) BEACON/RTRSYNC carry no ERB on the backbone; BEACON on
the anchor lane additionally publishes the router’s own
rdist=0so clients seedrdist. - Lane/preset model (
docs/RADIO.md):snr_rungin §3.2 is read straight offkPresets[].probe_snr_x10(core/include/lrc/presets.h);slot_msis the CAD cycle for the current lane preset; RD mode LANE_SCHED defers to the existing promotion lifecycle.
7. Hop pruning with retained return capacity
Section titled “7. Hop pruning with retained return capacity”Goal (maintainer’s framing): if frequency/SF/geometry allow, an intermediate relay should be droppable on the forward fast path (a closer/faster preset skips it), but the dropped hop may still be needed for the return, so return capacity is retained even while pruning forward.
7.1 Forward prune rule (keyed on preset/SF/SNR)
Section titled “7.1 Forward prune rule (keyed on preset/SF/SNR)”A relay R prunes itself out of the forward fast path for a flow when both:
(a) R observes a frame for the same router reach a node N with rdist_N ≤ rdist_R at a preset/SF rung ≥ R's own rung (N is faster-or-equal AND closer-or-equal)(b) R's prog_gain for that flow would be 0 after N's relay (clause-2 of §3.3)i.e. a faster, closer node makes R redundant on the way up. R stops relaying
that flow’s forward frames (saves airtime, the §3 suppression already does the
per-frame version; §7 makes it sticky per flow via a short prune timer so R
doesn’t re-enter the window every packet). Pruning is keyed on the rung
(preset/SF, via probe_snr_x10) and rdist, exactly the RADIO.md vocabulary.
7.2 Retained return capacity
Section titled “7.2 Retained return capacity”When R prunes forward, R may propose itself as a return candidate for the
affected client (advertised in R’s next uplink keepalive ERB TRAIL, or noted
by the router from the last frame that did pass through R before pruning). The
router keeps a bounded per-client return-candidate ring (§8) so that if
BROADCAST/LANE_SCHED return fails, RELAY_VIA can fall back to a
pruned-but-retained relay. Forward pruning never empties the return set. A
relay is only fully forgotten when it ages out of the bounded ring (or is never
confirmed), not when it is pruned forward.
Candidate ≠ trusted route. Entering the return-candidate ring is not a trust decision and never selects RELAY_VIA on its own. A candidate — whether it arrived via TRAIL, a keepalive advertisement, or a pre-prune observation — is eligible for RELAY_VIA only after it passes the observed-delivery gate (§4.2): the router must have measured a delivery attributable to it. TRAIL presence and the count of distinct candidate UIDs never raise a candidate’s selection priority (relay UIDs are free to forge — [SEC-H5]); only router-observed delivery does. An unconfirmed candidate ages out of the bounded ring without ever being selected.
This is the explicit answer to “dropped hops may still be needed for the return”: forward and return relay sets are separate bounded structures, prune touches only the forward one, and a relay becomes a usable return route only through observed delivery — never through a TRAIL byte.
8. Node state machines & data structures (bounded, ESP32/nRF52-safe)
Section titled “8. Node state machines & data structures (bounded, ESP32/nRF52-safe)”All structures are fixed-capacity, allocation-free on the radio path
(AGENTS.md: core/ portability, no hidden alloc), and degrade by eviction not
by growth. Sizes are defaults; routers with PSRAM scale the caps.
8.1 Every node
Section titled “8.1 Every node”rdist_table— router UID →{rdist:u4, last_snr_x10:i16, refreshed_ms}. FixedkMaxKnownRouters(default 8 edge / 64 router). LRU evict.- Dedup ring — unchanged (
docs/PROTOCOL.md§Dedup, 512/8192 keys). The ERB suppression (§3.3) reusesdedup_keyand the same ring; no new dedup state. pending_relay_slots— bounded ring of frames this node has scheduled to forward, each{dedup_key, fire_at_ms, rprog_at_schedule}. On overhearing a suppressing copy (§3.3) the matching slot is cancelled. FixedkMaxPendingRelays(default 16). A slot that fires is removed; an overflow drops the oldest (it was going to lose its window anyway).
8.2 Relay-capable node
Section titled “8.2 Relay-capable node”forward_prune— per-flow{router1, client1, prune_until_ms}, fixed ringkMaxPrunedFlows(default 32). §7.1. Pure timer state; expiry re-enables the §3 window for that flow.
8.3 Router (boundary node)
Section titled “8.3 Router (boundary node)”return_descriptors— client UID →ReturnDescriptor(§4.1). Bounded by the existingdirectory_limitdiscipline (docs/ROUTING.md§RTRSYNC, default 1024) and evicted by the same policy (unreachable first, stale same-UID, LRU). RDs are a column on the registration record the router already holds, not a new global map.return_candidates— per client, a small ring (default 4) of{relay_uid1, delivered:bool, last_snr_x10, refreshed_ms}for RELAY_VIA fallback (§7.2). A candidate is admissible to RELAY_VIA only withdelivered==true— set solely by the observed-delivery gate (§4.2), never by TRAIL presence. TRAIL/keepalive proposals enter withdelivered=falseand age out unconfirmed. Bounded; LRU. Cleared with the client’s registration.
State-machine sketch (router, per client UID):
uplink heard (ERB rprog, snr, trail) UNKNOWN ───────────────────────────────────▶ seed RD: mode by snr margin ▲ │ │ ttl_quart → 0 (decay timer) │ later uplink refresh │ ▼ └────────────── stale ◀──────────── { BROADCAST ⇄ RELAY_VIA ⇄ LANE_SCHED } (transitions on snr margin / promotion lifecycle / relay loss)The four RD modes are the states; transitions are driven by router-measured SNR
margin (BROADCAST↔RELAY_VIA), the existing promotion lifecycle (↔LANE_SCHED,
docs/RADIO.md §Lane promotion — reused, not reinvented), and decay
(→UNKNOWN). The entry into RELAY_VIA (via_uid1) is additionally gated on the
observed-delivery gate (§4.2): the first-contact seed is only ever BROADCAST
or UNKNOWN, and RELAY_VIA is reached only after a router-measured delivery names
a real relay — never from a TRAIL byte. No unbounded queues; every structure has
a fixed cap and an LRU/TTL eviction already mirrored on an existing chIRpChat
structure.
9. Interaction with existing chIRpChat mechanisms (keep vs replace)
Section titled “9. Interaction with existing chIRpChat mechanisms (keep vs replace)”| Mechanism | Disposition |
|---|---|
Dedup ring (§Dedup) |
Keep verbatim. dedup_key(SRC‖MSGID) is the suppression identity (§3.3); no change. |
FLAGS.PATH + 1-byte path block |
Replace with FLAGS.ERB + ERB (§6). Pinned-path forwarding gate removed; pin survives as the soft HINT byte. |
| Reverse-path return (WELCOME/ACK reversed bytes) | Replace with Return Descriptor (§4). WELCOME/ACK no longer carry reversed path bytes; the router’s RD governs return. |
FLAGS.VIA_TCP egress rule |
Keep verbatim. Hub regime (§5) is exactly the egress boundary; RF spent once per access net is unchanged. |
FLAGS.PRIO |
Keep; RACK and return frames set it so suppression/ACK preempt (docs/RADIO.md §Air discipline). |
CHANSYNC backfill (§Recovery) |
Keep verbatim. Backfill is a router broadcast on the downlink (§5); it never used reverse paths and doesn’t now. The async-reply story (§4) is why CHANSYNC’s “gap from a cursor, healed later” already works — RD makes the heal frame routable. |
| RTRSYNC directory / liveness / records | Keep verbatim. RD is per-router local state, not gossiped (a router’s last-mile to its own clients is nobody else’s business and would bloat RTRSYNC). Cross-router return uses the recipient router’s RD locally (§4.3). |
| LANEGRANT / promotion lifecycle | Keep verbatim and reuse as RD mode LANE_SCHED (§4.1). Promotion is the return path for promoted clients; the redesign adds no new grant wire. |
| Duty-cycle token bucket, CAD, beacon cadence | Keep. §3.2 slot_ms is the CAD cycle; the contention window lives inside the existing duty-cycle ledger; RACK is PRIO but still duty-accounted. |
Relay jitter U(0, airtime×2.5) |
Narrowed. Replaced by the §3.2 window for progress-bearing frames; retained as the fallback jitter for foreign/unknown-version blind-relay (which carries no rdist). |
Store-and-forward relay buffer (relay.store_forward_*) |
Keep. A relay holding a frame for a returning egress now stamps the held frame’s ERB rprog from current rdist when it finally forwards (so a delayed forward still suppresses correctly). |
Unknown-version blind relay (§Versioning) |
Keep. Such frames have no ERB the relay parses; they fall back to flat jitter and TTL-only forwarding, never to progress logic. |
Net: dedup, VIA_TCP, CHANSYNC, RTRSYNC, promotion, duty-cycle, store-forward, blind-relay are kept; the path block and reverse-path return are replaced; relay jitter is narrowed to a fallback.
10. SECURITY-SENSITIVE FIELDS (hand-off to the threat-model track)
Section titled “10. SECURITY-SENSITIVE FIELDS (hand-off to the threat-model track)”The current path block is UNSIGNED by design — excluded from the canonical
image (core/src/packet.cpp::canonical_image, mutable-flag mask). The ERB
inherits that exclusion: progress/trail bytes mutate in flight and cannot be
covered by SRC’s tag/signature. That is correct for forwarding, but it means
every routing field below is attacker-touchable by an unsigned/foreign
actor. Each is tagged for the consolidator and the parallel threat-model doc.
Stated assumptions are what the routing design needs to be safe; the
threat-model doc owns whether/how they are met.
| Tag | Field (where) | What a malicious/unsigned actor can do | Routing-design assumption (for the threat doc to confirm/deny) |
|---|---|---|---|
| [SEC-H1] | ERB.rprog (§6.2) |
Lie low (rprog=0) to win every contention window and black-hole an area; or lie high to never relay |
Progress is a liveness optimization, not an authorization: a lying rprog can degrade airtime but must not cause delivery to be claimed without the router’s verification. Assumption: ingress authenticity is still the router’s Tag8 check on the payload (docs/IDENTITY.md §4.1, unchanged), so a black-hole is a DoS, not a forgery. Threat doc must rate the DoS and decide if rprog needs a cheap rate-limit / plausibility bound (e.g. reject rprog < heard_rdist - 1). |
| [SEC-H2] | RACK / PROGRESS-ACK (§3.3, §6.4) |
Forge a RACK to suppress all real relays → silent black-hole of an entire edge mesh | This is the highest-value new attack surface. Assumption: a RACK MUST be authenticated as coming from a real router before it is allowed to suppress — minimally a Tag8 under the session/anchor context, or accepted-only-from-a-known-rtr_short whose BEACON the node has validated. Hand-off: threat doc must specify the RACK authenticator. Until then, treat RACK as advisory: it may shorten a relay’s window but not cancel it outright (degrade gracefully). |
| [SEC-H3] | ERB.HINT (§6.2) |
Inject a false next-relay hint to steer frames into a sink or a congested node | HINT is a bias only and cannot gate forwarding (§3), so worst case is suboptimal routing, not loss. Assumption: unsigned HINT is acceptable because progress-ranked relaying overrides it; threat doc only needs to confirm HINT can never suppress a better relay. |
| [SEC-H4] | SNR claims feeding CWsize (§3.2) |
A node cannot forge another node’s measured SNR (SNR is locally measured on receive), but can lie about its own progress benefit | Local SNR is measured, not received, so it is not attacker-controlled; only rprog (covered by [SEC-H1]) is. Assumption: no SNR value travels on the wire in a way that feeds another node’s window; if a future optimization puts a claimed SNR in the ERB, it MUST be re-tagged. |
| [SEC-H5] | ERB.TRAIL[] breadcrumbs (§6.2) |
Append a forged UID byte to poison a router’s RD / return-candidate set, steering returns through an attacker | RESOLVED (Option B): TRAIL is demoted to a diagnostic / unverified candidate hint and feeds NO trust decision. The observed-delivery gate (§4.2) is now a hard normative rule: a relay is admitted to the RD / return-candidate ring / RELAY_VIA only after the router has observed it deliver a frame (router-measured reception attributable to that relay), never from TRAIL presence; first-contact RD is BROADCAST or UNKNOWN, never TRAIL-seeded RELAY_VIA. TRAIL may only order candidates that already passed the gate. Consequence: a forged TRAIL byte yields at worst a mis-ordered already-validated candidate or one wasted diagnostic field — a return via an unconfirmed relay is never even attempted, so there is nothing to “fail” into a hijack. No per-hop MAC is needed and the canonical image is unchanged (the routing track is now self-consistent with this row’s own assumption). RD eviction can’t be forced by trail spam (bounded ring, §8.3). |
| [SEC-H6] | Return Descriptor edits and seed (§4) | RDs are router-local, not on the wire — an attacker cannot edit them directly, only influence them via [SEC-H1]/[SEC-H5] inputs. The remaining risk was the seed on first contact. | RESOLVED (Issue 3): because RDs are never gossiped (kept out of RTRSYNC, §9), there is no remote-edit surface. The seed is now also safe: the first-contact RD rule (§4.2) mandates the seed be router-measured BROADCAST or UNKNOWN, never TRAIL-seeded RELAY_VIA, so an RD is never pinned from unauthenticated forward data before an observed delivery. All RD risk reduces to the (now-bounded) [SEC-H5] input-poisoning row. The “RD not gossiped” decision stands (a deliberate confidentiality + integrity choice). |
| [SEC-H7] | Prune decisions (§7) | A forged “faster closer node exists” observation could trick a relay into pruning itself, removing a needed forward hop | Prune is driven by overheard real frames (must carry a valid router-converging frame), and is time-bounded (prune_until_ms) and self-healing (expiry re-enters the window). Assumption: worst case is a transient forward gap that the §3 window re-fills after the prune timer; threat doc must confirm prune can’t be made permanent by a sustained forgery (bound the prune timer hard, default ≤ a few seconds). |
| [SEC-H8] | LANEGRANT (RD LANE_SCHED) |
Unchanged from today | Already Tag8’d under the session key with the issuing-router src check (docs/ROUTING.md §Anchor offload, audit Findings 1–3 resolved). Assumption: no new exposure; threat doc inherits the existing analysis. |
Single most important hand-off: [SEC-H2] (RACK authentication). The suppression-collapse signal is the one new field whose forgery causes silent loss rather than mere inefficiency. The routing design assumes RACK must be router-authenticated before it can hard-cancel a relay; if the threat-model track decides that authenticator is too expensive for the edge, the fallback is “RACK shortens but never cancels,” which keeps the design safe at the cost of some redundant relays. The consolidator must resolve this before any RF sign-off.
What must be signed/authenticated for the model to be safe (summary list for the consolidator):
- Payload ingress authenticity — unchanged: the router’s Tag8/
SIGNED_FULLcheck onSRC’s payload remains the authority for “who said this” (docs/IDENTITY.md§4). The ERB changes routing, not authenticity. - RACK origin — must be authenticated as a real router [SEC-H2].
- RD-input gating — TRAIL delivery-validation [SEC-H5] is no longer
optional: the observed-delivery gate (§4.2) is a hard rule, so no RD /
RELAY_VIA / return-candidate trust decision is ever taken from a TRAIL byte.
The
rproglower bound [SEC-H1] remains a soft DoS-bounding check the threat doc may require or leave as optional hardening. - Everything else in the ERB is intentionally unauthenticated (HINT, TRAIL, rprog) and the design is built to degrade, not lose under forgery — safe precisely because, under the observed-delivery gate, none of these feeds a trust decision (TRAIL is demoted to a diagnostic candidate hint; trust comes only from router-observed delivery). The threat doc confirms each degradation is acceptable.
11. Failure modes & open questions
Section titled “11. Failure modes & open questions”Failure modes the design tolerates:
- Lying
rprogblack-hole → bounded DoS, not forgery ([SEC-H1]); the router never confirms delivery it didn’t receive. - Stale RD on async reply → decays to UNKNOWN, reply re-discovers via §3.
- Pruned-then-needed hop → return-candidate set retains it (§7.2).
- Suppression storm / nobody relays →
BACKSTOP+ the §3 window guarantee at least one relay fires; if all suppress wrongly, dedup-driven retry (existing ACK loop) re-injects the frame.
Open questions I could not fully resolve:
-
RACK authentication cost vs. edge reality [SEC-H2]. An edge relay that has not registered with the router (a pure foreign relay willing to help) has no session key to verify a router’s RACK Tag8. Does such a relay get to be suppressed at all, or only progress-suppressed by overhearing the actual forwarded frame? The “RACK shortens but never cancels” fallback works but loses the instant-collapse win. Needs the threat-model track + an RF airtime measurement to settle. (Hardware sign-off, AGENTS.md rule 5.)
-
CWsizeconstant calibration.k_snr,k_prog,CW_BASE, and theprog_gainclamp are chosen by analogy to Meshtastic’s 2..7 range [1][2] and chIRpChat’s preset rungs, not measured. The interaction between the SNR term (push coverage outward) and the progress term (reward forward gain) could oscillate or starve a mid-distance relay in some geometries. This is a simulation/field-tuning item (chaos harness, ROADMAP), not a wire question. -
RD mode flapping vs. mobility. A client on a ridge whose direct-vs-relay reception flickers could flap BROADCAST↔RELAY_VIA each reply, doubling return airtime. A hysteresis like the promotion lifecycle’s demote-run (
docs/RADIO.md§Lane promotion) is the obvious fix, but the right run-length and SNR margin for return (vs. uplink promotion) is unestablished and likely region/preset dependent.
References
Section titled “References”- Meshtastic — Mesh Broadcast Algorithm (managed flood: SNR-weighted contention window CW size 2..7, suppress-on-overhear, 3-bit hop limit, ROUTER/REPEATER rebroadcast-even-if-overheard exception, 2.6 next-hop DM routing). https://meshtastic.org/docs/overview/mesh-algo/
- Meshtastic — Why Meshtastic Uses Managed Flood Routing and discussion of
the outward-flood SNR→delay relationship that chIRpChat intentionally does not
copy;
delay = (2·CWmax) + random(0, 2^CWsize)× CAD-derived slot time. https://meshtastic.org/blog/why-meshtastic-uses-managed-flood-routing/ ; GitHub discussion #117 (CWmin/CWmax, slot = CAD cycle). https://github.com/orgs/meshtastic/discussions/117 - Meshtastic — Demystifying ROUTER_LATE (early/default/late contention windows; infrastructure roles defer to preserve normal routing). The hub boundary (§5) and the progress-ranked window (§3) are chIRpChat’s structural alternative to role-tiered windows. https://meshtastic.org/blog/demystifying-router-late/
- MeshCore — Network Protocol (flood-discovery-then-direct, path-recording that chIRpChat’s old block copied and this design replaces). https://deepwiki.com/meshcore-dev/MeshCore/7-network-protocol
- Reticulum — Understanding Reticulum (next-hop overlay where each Transport Node knows only the most direct next hop — the conceptual cousin of the RD’s “router holds the return, not the chain”). https://reticulum.network/manual/understanding.html
In-repo normative sources: docs/ROUTING.md, docs/PROTOCOL.md,
docs/RADIO.md, core/include/lrc/packet.h, core/src/packet.cpp,
core/include/lrc/presets.h, and the research base
docs/research/COMPARISON_THESIS.md.