Skip to content

Radio: lanes & presets

The radio layer’s job is to make a half-duplex, single-channel-at-a-time transceiver behave like a multi-speed switched network. The core abstraction is the lane: a (frequency, preset, schedule) triple. A TCP link is just a lane with infinite speed and no schedule.

Eight presets, chosen to cover the Meshtastic/MeshCore operating envelope (values cross-checked against Meshtastic’s MeshRadio.h table):

id Name SF BW kHz CR ~kbps Role
P0 ANCHOR 12 125 4/8 0.18 beacons, discovery, last-resort uplink
P1 FRINGE 11 125 4/8 0.34 very long links, fringe coverage
P2 REACH 10 250 4/6 1.4 long client links
P3 FIELD 9 250 4/5 2.6 default client lane
P4 CRUISE 8 250 4/5 4.7 good client links, slow backbone
P5 LOCAL 7 250 4/5 8.5 near clients, backbone
P6 TRUNK 7 500 4/5 17 backbone, DCC bursts
P7 BURST 5 500 4/5 39 short-range backbone, DCC bursts

The names are link-role terminology, not speeds for their own sake: FRINGE holds the fringe of a cell, FIELD is the default lane a client in the field sits on, TRUNK is the backbone trunk line, BURST carries DCC bursts. The preset id is what travels on the wire; names are display/config aliases (lowercase accepted), so renames are never a wire event.

All eight are decodable by SX126x/SX127x-class silicon. Sync word and preamble follow LoRa public-network conventions; chIRpChat traffic is additionally self-identified by the LPP version bits, so coexistence with foreign LoRa traffic fails safe (undecodable or unknown-version → ignored).

Frequency slots are computed within the regional band plan (EU868/US915/AU915/AS923… tables in core/include/lrc/presets.h) as slot = SipHash(region ‖ lane_index) % nslots offsets from the band start — deterministic, so two nodes with the same config land on the same frequencies without negotiation (same idea as Meshtastic’s name-hash slots, minus the string hashing). The allocator then deterministically probes forward from the hashed slot past any whose passband would fall on — or within a guard band of — a busy neighbouring-network channel, so chIRpChat isn’t a bad neighbour to the heaviest LoRa traffic in a region. Two sources are dodged (presets.h): MeshCore’s fixed channels and every Meshtastic modem preset.

For MeshCore that means the coordinated US/CAN default (910.525 MHz, BW62.5 — the single busiest LoRa-mesh channel in US915), plus a couple of high-traffic metros that deliberately run off it: Southern California / “West Coast Mesh” (927.875 MHz, up by the 928 MHz band edge) and Sacramento / Sac Valley Mesh (909.875 MHz). Those two are region-specific — a chIRpChat node outside those metros loses nothing by keeping the slot clear, and SoCal’s sits high enough it only ever costs the topmost BW500 lane slot. Each is a {center, bandwidth} entry in kMeshAvoidChannels, cited in-line.

For Meshtastic that’s every modem preset — LongFast, MediumFast/MediumSlow, ShortFast/ShortSlow, LongModerate/LongSlow, ShortTurbo, and the deprecated VeryLongSlow. Because Meshtastic places each preset’s default channel with a djb2 name-hash, those land on different frequencies, so chIRpChat reproduces Meshtastic’s exact slot calculation (meshtastic_preset_freq_khz, verified against the published US915 LongFast 906.875 MHz; bandwidth is carried in Hz so the 62.5 kHz presets place exactly). The guard band widens with the neighbour’s bandwidth, so the 500 kHz ShortTurbo reserves correspondingly more room. Both ends walk the same slots, so they still agree without negotiation; in a band too narrow to dodge a co-located channel (e.g. EU868’s 250 kHz subband) it falls back to the hashed slot. Every preset is listed even though a niche one carries little traffic today — any can become a local default — and the cost is small: ~9 channels out of US915’s 104. On US915 the chIRpChat anchor (908.687 MHz) and lanes are already clear of all of them, so nothing moves.

A router runs a small number of lanes concurrently in time-division, since the radio can only do one at once (multi-radio routers — Linux lrcd with several HATs — run them truly in parallel, one radio per lane). The lane plan is the router’s published schedule, carried in every BEACON:

BEACON {
rtr_short(2), RK_fp(8), flags(1: busy, fed_member, accepts_new),
n_lanes(1),
lanes[n]: { lane_id(1), preset(1), freq_slot(1), period_s(1),
offset_s(1), window_s(1) },
fed_id(4), time(4), boot_id(4)
}

period/offset define when the router parks its receiver on that lane (e.g. lane 2 = P5 every 10 s at offset 3 for 2 s). Clients TX to the router inside the lane’s window; the router TXes its downlink batches at the window head, then listens for the remainder of the window (“listen-after-talk”) — this is the answer to “how does anyone know when to speak on which frequency/SF”: the router publishes its calendar, then opens the floor after each of its own transmissions. LANEPROBE is the fixed 8-byte “floor is open, anyone with traffic for me speak now” solicitation emitted when the router has nothing to send but a non-anchor window is open; it names the lane and remaining listen budget, while CAD/backoff still gates actual uplink TX. The host-tested LaneProbeEmitter builds one probe frame per listen window from the same lane plan that BEACON advertises. LaneSchedule::runtime_decision is the matching host-tested single-radio runtime helper: it chooses the fastest currently open non-anchor lane that the radio can support, subtracts a caller-provided guard interval from the usable window, and falls back to anchor when the edge is too close. Firmware wires the probe emitter and runtime retune service through the existing TX queue/CAD path and leaves the fresh-device receive-only guard intact; the default firmware plan is still the single always-on anchor lane until local multi-lane runtime retuning is enabled. The cold lrckv tier can persist configured lane definitions as a laneplan record, but it stores only LaneDef schedule data; BEACON runtime fields (RK_fp, time, boot_id, and router flags) are filled when the beacon is emitted and are not restored from flash. Allowlisted signed Admin can update that same store with lane.plan using the compact lane_id:preset:freq_slot:period_s:offset_s:window_s rows documented in the user guide. ESP32 firmware restores stored plans from that record. Anchor-only plans are advertised immediately; non-anchor rows are advertised and used by the single-radio retune service only after the local console command lane retune on is set on that device. lane.plan stores intent, not remote RF authorization. BeaconCadenceController is the host-tested anchor-beacon congestion policy: the first beacon is immediate, busy/not-accepting/client-active routers use the active cadence, idle routers use the idle cadence, and an idle router that has recently heard a healthy same-federation neighbor backs off to the suppressed cadence while still respecting the configured maximum interval (default 600 s). Visibility-critical periods such as burst-mode operation can force the minimum cadence (default 60 s). ESP32 firmware wires this decision into its router beacon loop and marks cadence when a BEACON is accepted into the TX queue; actual TX still goes through the existing receive-only gate, queue, and CAD path.

Beacon suppression keeps routers off the anchor; LaneSchedule::promote_client is the matching host-tested policy that keeps clients off it. It is the router-side mirror of pick_uplink_lane: once the router has more than min_clients registered (the anchor is congested enough to matter), a client whose uplink the router measured with margin over a faster lane’s probe floor (default 3 dB over presets.h) and whose caps allow that preset is promoted to the fastest such lane. A per-client debounce (repromote_hold_ms, default 60 s) keeps the ladder climb monotonic instead of thrashing, and below the congestion threshold clients are simply left on the dial tone. The policy is pure; the decision travels to the client as a LANEGRANT unicast (PROTOCOL.md §Lane promotion) Tag8’d with the session key. ESP32 firmware wires both ends: a router runs promote_client against the SNR it measured on a client’s HELLO and emits the grant right after WELCOME (lane.grant_tx); a client validates the grant is for it and from its router (lane.grant_rx) and, when its local lane retune on RF opt-in is set, retunes its uplink onto the granted lane until the TTL lapses, then falls back to the anchor. A grant only fires when the router actually advertises a faster lane the client can sustain.

promote_client decides the first move; LaneSchedule::promote_lifecycle is the host-tested wrapper that manages the grant’s life after that, since a LANEGRANT TTL is finite. Given the live grant the router stored (lane, TTL, expiry), the latest uplink SNR, recent-uplink health, and the same congestion input, it returns one of four actions: GRANT a first lane (it defers the whole “is it worth it / which lane / debounce” judgement to promote_client, so GRANT lands on exactly the one-shot lane); RENEW the live grant before it expires — only inside renew_lead_ms of the stored expiry, only for a grant that carried a real TTL, and only while the link still sustains the lane, re-issued onto the same lane or a faster one if the link improved so renewals can also climb; DEMOTE back to the anchor when the TTL lapsed, when a run of failed/absent uplinks (demote_fail_run) says the client went dark, or when the uplink SNR sits below the granted lane’s sustain floor (its preset probe floor plus demote_snr_margin_x10) for demote_snr_run consecutive samples — the hysteresis run keeps a single dip from dropping the lane, and a recovered latest sample cancels a stale run; or HOLD. Demotion is evaluated before renewal so a decaying link is pulled down rather than re-leased onto a lane it can no longer hold. Like promote_client it owns no per-client map — the caller stores the grant and link signals and passes them back — and it is pure, allocation-free, and RF-neutral: it computes the intent to (re)issue or revoke a grant and authorizes no TX or retune. Firmware/lrcd translate GRANT/RENEW into the LANEGRANT unicast and DEMOTE into the client’s anchor fallback.

Clients needing no traffic just sleep through windows (POWER.md). The anchor lane (P0) is always lane 0 and always reachable — it’s the network’s dial tone.

boot_id is the router’s current incarnation, generated fresh on boot and repeated in signed WELCOME. Unsigned BEACON boot IDs are advisory; once a signed WELCOME confirms a changed nonzero boot ID, clients flush stale per-router mirror and gap state instead of treating the reset as a huge sequence gap.

During registration and once per relink_interval afterward:

  1. Client hears BEACON on P0, sends HELLO on P0 or directly on a faster advertised lane if RSSI/SNR of the beacon clears that lane’s threshold (thresholds per preset in presets.h, derived from SX1262 sensitivity floors: e.g. SNR ≥ -7.5 dB → try P3, ≥ 5 dB → try P5).
  2. WELCOME comes back on the same lane; its reported RSSI/SNR of the HELLO lets the client decide to retry one rung higher (“ladder climb”, max 2 climbs per registration to bound the chatter).
  3. The negotiated rung is pinned per link and re-probed on 5 consecutive failures (down) or weekly (up).

Routers with band-pass filter constraints (cavity/SAW-filtered routers that can only emit in, say, a 5 MHz window) advertise only freq slots inside their window — the constraint is config (rf.freq_min/freq_max and an optional explicit slot allowlist), and the lane plan simply never offers what the hardware can’t do.

A relay (router built with RELAY_ONLY) parks RX on lane A and re-TXes held packets on lane B per its own mini lane plan — receive on P1 from the valley, forward on P6 to the hilltop router. This is plain store-and-forward with the relay appearing in path blocks like any hop; no protocol changes. Relays answer CHANSYNC from their ring buffer when they can (PROTOCOL.md), which converts them from dumb pipes into edge caches for free.

The federation section of ROUTING.md does the heavy lifting: different routers run different lanes on different frequencies simultaneously, and the backbone merges streams. Within one router, lanes multiplex by schedule. Net effect: total in-flight capacity scales with (number of routers × average lanes), while every member of #pizza still converges on the same message list — convergence is the sequencer + backfill mechanism, not shared RF.

A single-radio router can only listen to one lane at a time, so it time-divides them (LaneSchedule::runtime_decision). A multi-radio router — Linux lrcd with several SX1262 HATs — can instead park one radio per lane and listen on all of them at once. LaneSchedule::assign_radios is the host-tested pure planner that computes that static per-radio assignment from the same advertised lane plan: given a radio count N and the router’s radio caps, it returns one RadioAssignment per radio (in_use, lane_index, lane_id, preset, freq_slot). Radio 0 always holds the anchor lane (P0, period_s == 0) so the dial tone is never dropped; radios 1.. then cover the fastest distinct caps-allowed non-anchor lanes, fastest first (preset index == speed rank, via preset_allowed), one radio per lane. Surplus radios past the usable lanes come back in_use == false (parked idle); when N is smaller than the lane count the uncovered lanes are simply not listed — the caller leaves them to the anchor radio’s existing single-radio time-division, and the planner invents no new scheduling. Output is deterministic for a given input and the helper is allocation-free (fixed kMaxRadios cap, caller-provided buffer). Like the rest of the lane helpers it computes intent only: the actual per-radio RadioLib retune stays firmware/RF work and needs human sign-off (AGENTS.md rule 5); assign_radios authorizes no TX.

DCC SEND of a 100 KB file at P3 would take ~5 minutes of monopolized airtime; at P7 it’s ~20 s. The intended choreography, with OFFER/ACCEPT controls currently built:

  1. IRC gateway intercepts DCC SEND CTCP (ARCHITECTURE.md), creates DCCCTL OFFER to the recipient through normal routing.
  2. Recipient’s client auto- or user-accepts → DCCCTL ACCEPT.
  3. The two access routers negotiate a burst lane: the sender’s router asks each node on the path (DCCCTL LANE_GRANT hop-by-hop, requires Tag8 from each hop’s registration) for an RF capability intersection — each node advertises freq_min/freq_max/max_preset in its HELLO caps, so the intersection is computed, not discovered by failure. The built DCCCTL LANE_GRANT control fixes {stream_id, preset, freq_slot, token(4), ttl_s, airtime_budget_ms} and is delivered reliably to local sidecars. The built DccBurstGrantTracker applies Clock-based TTL expiry, renewal, and matching CLOSE teardown; pure core helpers turn an active grant into a send intent and combine it with LaneSchedule::runtime_decision to report ready/yield/anchor-fallback runtime state. DccBurstLeaseMachine wraps those helpers into the host-tested lease contract: idle, waiting for a matching window, ready, anchor/mismatch yield, expired, and closed, including token release and a force-minimum beacon-cadence signal while the lease is live. The runtime plan clamps chunks by grant airtime, the usable lane window, and the caller’s cap. DccBurstRfIntent is the pure adapter handoff to hardware: only a ready matching lease maps to concrete frequency/preset radio parameters and a nonzero chunk budget; waiting, yielded, expired, or closed leases preserve control state without authorizing a retune. The lrcd TCP sidecar now issues a receiver-side grant after ACCEPT, renews it while the receive buffer is incomplete, and uses the active tracked grant on the sender to cap DCCDATA chunks by preset airtime budget. The remaining device work is wiring a board RF driver to that intent; configured firmware lane-plan retuning already uses the same schedule helper for ordinary non-anchor lane windows.
  4. Participants (only the path nodes!) retune for up to ttl_s (≤ 120 s, renewable), move the stream at burst speed, then fall back to their lane plan. Routers keep their anchor-lane beacon cadence even mid-burst (one beacon per 60 s minimum) so the access network never goes dark; on multi-radio lrcd routers the burst gets its own radio and nothing pauses at all.
  5. If no common window exists (disjoint band-pass filters mid-path), the grant falls back to in-plan slots at the bottleneck preset — slower, never broken. Chunk-level RESUME bitmaps (PROTOCOL.md §DCC) make the transfer indifferent to lane changes mid-flight.
  • CAD before every TX, randomized backoff on busy (120–480 ms).
  • Relay jitter U(0, 2.5 × airtime).
  • Per-sub-band duty-cycle token bucket — lrc::AirtimeLedger (core/include/lrc/airtime.h), a pure sliding-window budget keyed by (lane index, sub-band index). The firmware radio loop consults admit(lane, bytes, now, policy, sub_band) before each TX; on a refused TX it CAD-backs off and bumps Counter::tx_duty_defer, and on a committed TX calls charge(). The AirtimePolicy carries the sub-band’s regulatory cap in basis points (duty_bp, 0..10000 — EU868 g2’s 0.1% is duty_bp = 10; US915 is duty_bp = 10000, unlimited), and duty_pct_now()/remaining_duty_bp() are the values telemetry and other decision layers (e.g. credit re-origination budgets) read. The host harness exercises the same policy: VirtualRadio::set_ledger() routes simulated TX through the real ledger so duty deferral is validated in CI without radios (docs/TESTING.md). See “Regulatory regions” below for where a sub-band index comes from.
  • TTL ≤ 7, discovery floods TTL ≤ 3, all rate-limited per origin.
  • Every TX decision consults the airtime ledger (TELEMETRY.md) — the same numbers users see, so observed behavior and telemetry can’t drift apart.

lrc::BandPlan (core/include/lrc/presets.h) answers where a lane sits in frequency space — pure geometry. It carries no power or duty numbers; those live in a separate, richer engine because a single flat row cannot express EU868’s five ETSI sub-bands, KR920’s two-tier EIRP, or Japan’s LBT overlay on a shared frequency plan. docs/research/REGULATORY_SURVEY.md is the normative source for every number below; a discrepancy between that document and the code is a bug, not a license to silently pick a value.

core/include/lrc/region.h (hand-written, the decision layer) defines:

struct SubBand {
uint32_t start_khz, end_khz;
int8_t max_dbm; // regulatory ceiling
bool erp; // true: max_dbm is ERP; false: EIRP
uint16_t duty_bp; // basis points, 0..10000 (10000 = unlimited)
};
struct LbtParams { uint16_t cs_time_us; int8_t cs_threshold_dbm; uint16_t max_tx_ms; };
struct RegionPlan {
const char* name;
const SubBand* sub_bands; uint8_t sub_band_count;
uint16_t dwell_ms; // 0 = no cap; 400 for US915/AU915/AS923
bool requires_lbt; // legally mandatory, independent of sourced lbt data
LbtParams lbt;
bool duty_or_lbt_alternative; // EU868/RU864/JP923: either mechanism is legal
};

core/include/lrc/region_table_gen.h is a generated, committed header holding the concrete RegionId enum and kRegionPlans[] table — produced by python3 tools/gen_regions.py from the human-reviewed tools/regions_table.py. To change a region’s numbers: edit the table, regenerate, commit both files together. python3 tools/gen_regions.py --check (also runnable as python3 tests/test_region_codegen.py) fails if the committed header has drifted from the table — the CI-checkable regen rule, run alongside the rest of the host test suite rather than as a separate GitHub Actions workflow.

Encoded regions: US915, AU915, EU868 (5 ETSI sub-bands), EU433, AS923-1/-2/-3, JP923 (Japan’s ARIB overlay on the AS923-1 frequency plan — its own row, not AS923-1 itself, because the frequency plan is shared but the channel-access rule is completely different), KR920 (2-tier EIRP), IN865, RU864 (unlicensed) + RU916 (licensed AS923-3-style overlay — Russia’s two simultaneous legal regimes, two distinct rows), NZ865, MY919 (2 sub-bands), CN470. Deliberately deferred regions (AS923-4/Israel’s unverified LBT mandate, deprecated CN779, several single-sub-band regions with no design pressure of their own, GB/UK pending its own IR 2030 citation pass) are listed with reasons in tools/regions_table.py’s module docstring.

AirtimePolicy::duty_bp (core/include/lrc/airtime.h) is basis points (1/100 of a percent) so EU868 g2’s 0.1% is exactly representable (duty_bp = 10) without floating point on the radio path. AirtimeLedger slots are keyed by (lane index, sub-band index) (region_sub_band_index_for_freq(region, freq_khz) resolves the index from the frequency the caller is about to transmit on) so a router spanning EU868’s g1 (1%) and g3 (10%) sub-bands never lets a g3 burst borrow g1’s separate budget. AirtimeLedger::remaining_duty_bp() exports current headroom (basis points remaining in the sub-band’s own window, not the static duty_bp ceiling) for other decision layers — e.g. credit re-origination budgets — to couple to.

Dwell time: per-transmission, independent of duty

Section titled “Dwell time: per-transmission, independent of duty”

US915/AU915 (FCC 15.247(f) FHSS) and the AS923 family cap a single transmission’s airtime at 400 ms regardless of remaining duty budget — lrc::dwell_admits(region, airtime_ms) is a hard per-TX ceiling, checked in addition to the duty ledger, never folded into it (duty recovers over a sliding window; dwell is a per-transmission legal cap that never does).

LBT: policy objects through the existing CAD seam

Section titled “LBT: policy objects through the existing CAD seam”

lrc::lbt_admit(region, LbtObservation) (core/include/lrc/region.h) is a pure decision function for the firmware’s existing CAD-before-TX seam (radio.scanChannel() in service_radio()) and the host’s VirtualRadio/tests/virtual_radio.h equivalent — it does not perform a carrier-sense scan itself, only judges what the caller already measured. Required in Japan (ARIB STD-T108: ≥128 µs carrier-sense at −80 dBm, <4 s continuous TX), Korea (KR920 — LoRaWAN “exclusively uses LBT” per RP002, but the primary carrier-sense timing was never sourced), and China (CN470 — LBT AFA + hard 1 s/channel cap). A region with requires_lbt = true but no sourced carrier-sense numbers (KR920 today) fails closed (LbtResult::DeferUnverifiedParams) rather than silently admitting because the fields happen to read zero. The observation feeding the decision comes from the embedder: Node::Config::lbt_observe is the seam a host embedding wires its carrier-sense pass into, and absent that seam the default observation fails closed on every requires_lbt region (a node with no carrier-sense path cannot transmit under an LBT-mandatory regime, by construction). On firmware, calibrating radio.scanChannel() to an ARIB-verifiable 128 µs/−80 dBm pass is RF-gated, human-signed work (AGENTS.md rule 5) — until it lands, JP923/KR920/CN470 are receive-only on hardware: the firmware gate refuses their TX with Counter::region_lbt_deferred. EU868/RU864/JP923’s ≤1% exemption use LBT as a legal alternative to duty-cycling (duty_or_lbt_alternative = true), not a requirement.

Every SubBand tags its ceiling’s unit (erp); ERP = EIRP − 2.15 dB (sub_band_eirp_ceiling_dbm()), so a table mixing EIRP-denominated regions (US915, most of AS923/KR920/IN865) with ERP-denominated ones (most of EU868’s sub-bands) never silently mismatches by ~2 dB. Firmware only controls conducted (PA output) power; conducted_power_cap_dbm(sub_band, antenna_gain_dbi, cable_loss_dbi) computes the legal conducted-power ceiling for the operator’s actual antenna — a high-gain antenna must clamp conducted power down to hold the same EIRP a stock antenna reaches at full power (RP002’s US915 text makes this explicit for directional gain over +6 dBi). Antenna gain is a per-node operator-configured value, not a per-region constant.

Extends the existing ship-OFF invariant (firmware boots receive-only until an operator opts in — “TX SAFETY” comment, firmware/src/main.cpp): a region must also be explicitly selected before tx on is honored, so a freshly flashed board can never transmit under an unspecified regulatory regime. lrc::region_gate_check(RegionGateState) is the pure decision; firmware’s console region <name> command sets FwConfig::region (persisted, empty by default), and tx on refuses with Counter::region_unset_refusal when no region resolves. region with no argument reports the current setting (or that it’s unset/unrecognized).

Enforcement: where the gate lives, and drop-don’t-queue

Section titled “Enforcement: where the gate lives, and drop-don’t-queue”

The engine above is ENFORCED, not advisory, at exactly one choke point per embedding:

  • lrc::Nodeemit_radio_frame() (core/src/node.cpp), the single radio egress point every fresh TX, relay, and store-and-forward flush passes through, consults radio_tx_admitted(): sub-band resolution (out-of-band → refuse, region.out_of_band), dwell (region.dwell_clamped), LBT where mandatory (region.lbt_deferred/region.lbt_admit), then the per-sub-band duty ledger (tx.duty_defer, charged on admit). The embedder arms it with Node::set_region_tx_context() — region plan, current TX frequency/preset/lane, duty window. No context (the default) = no enforcement: a TCP-only daemon or a host test makes no legal claims about spectrum it never keys. TCP egress is never gated — no RF law applies to a wire.
  • Firmwareregion_tx_admitted() in the radio loop’s TX branch (firmware/src/main.cpp), judged against the lane the radio is parked on at actual-TX time, with the region resolved from the operator’s saved region setting. The firmware does not also set Node’s context: the lane can retune between Node queueing a frame and the frame airing, so queue-time accounting would charge the wrong lane — exactly one layer per embedding owns enforcement (the double-charge rule, documented at Node::RegionTxContext).

Refused frames are dropped, with the reason counted — never queued. The rejected alternative was a bounded/TTL’d deferred-frame queue in front of the radio (“hold the frame until budget frees up”). Rejected because:

  1. Starvation outlasts any RAM-bounded queue. A duty window is regulatory-hour-scale: EU868 g1 at 1% is 36 s of airtime per hour, g2 is 3.6 s. A saturated cell starves for minutes — a queue deep enough to ride that out is megabytes of PSRAM (already budgeted by the Arena for message stores, on hardware where every KB is contended), and a queue shallow enough to afford overflows immediately, which is a drop with extra steps and a stale-data tax.
  2. The transport already owns recovery. Sequenced channel traffic heals through CHANSYNC gap backfill (a receiver that missed seq N requests it when capacity returns); DMs and admin ops re-send through the pending-ack retry machinery (each retry re-enters the gate and passes once the window rolls); offline delivery is the mailbox’s job; PATH relay frames already have their own bounded store-and-forward. A deferred-TX queue would be a second, competing retry layer with its own staleness and ordering bugs, in front of machinery that was designed for exactly this.
  3. Stale frames waste the budget that starved them. When a g-band window finally rolls, spending the freed airtime on an hour-old chat message (that CHANSYNC would have healed anyway, deduped at every receiver) is strictly worse than spending it on live traffic.

A duty-starved EU868 router under sustained load therefore sheds load at the gate (visible as tx.duty_defer in stats and the observatory’s airtime view), keeps its budget for what the recovery layers actually request, and resumes normal service the moment the window rolls — behavior pinned by tests/scenarios/eu868_duty_starve.scn (sim), the region_node_gate_* tests (Node gate), and the firmware build.