Stage 3 / Checkpoint C deliverable. Status: Draft normative spec. Date: 2026-06-09.
Depends on: packet-format.md (BEACON/SLOTMAP/CELL_DESC fields, P plane bit, TC).
Validated by: sim-result-v3 (TDMA hidden-node elimination), v4 (adaptive modulation/reuse),
v6 (epoch-hash fallback ordering), v7 (scenario suite).
Normative language per RFC 2119/8174 (
packet-format.mdpreamble). This document specifies when a node may transmit and how the channel is shared. It defines two cooperating planes — a scheduled (TDMA) data plane and a contention plane — and the rules for switching between a coordinated and a coordinator-free regime without losing the network.
Loomwave's central result (hidden-node-analysis.md, sim-result-v1/v3) is that carrier sense
cannot fix the hidden-node problem — a node cannot defer to a carrier it cannot hear — so the data
plane MUST NOT rely on CSMA for collision avoidance at scale. The MAC is therefore a hybrid:
v3 measured
99.2–99.8% delivery at K = 10–50 mutually-hidden senders vs. CSMA's 88→50%, with < 5 collisions vs.
thousands, at 98–99% slot efficiency.v6 showed beats CSMA for hidden nodes at every load because it
needs no carrier sense.The plane of a given frame is carried in the P bit (packet-format.md §3): P=1 scheduled, P=0
contention. A node MUST transmit a frame only in a region of the superframe that matches its P
bit and the rules below.
Under a coordinator, time is divided into superframes of length FRAME_T ms (announced in the
BEACON, packet-format.md §8.1). Each superframe is:
|<--------------------------- FRAME_T ms --------------------------->|
| BEACON | G | scheduled data slots (SLOTMAP) | contention window |
| (B0) | | s0 | s1 | s2 | ... | s(N-1) | c0 | c1 | ... |
^guard ^each slot = ToA(cell SF, max frame) + guard
CELL_DESC, and carries the SLOTMAP and contention-window
pointer NEXT_PTR.SLOTMAP_LEN slots, each owned by the 24-bit address at that index. Slot
width = airtime of a maximum-size frame at the cell's (SF, BW) plus one guard interval G
(§3). An entry of 0x000000 marks an open slot usable as extra contention capacity.NEXT_PTR-delimited tail of the superframe for the contention plane (§5).A coordinator MUST size the scheduled region so the superframe (beacon + slots + contention
window) does not exceed FRAME_T, and MUST keep the contention window ≥ a floor (§5.3) so newly
arrived nodes always have a way in.
Geometry is computed from airtimes, not chosen (Stage-4 field rule). The contention window MUST satisfy
CW ≥ ToA(largest contention-plane frame) + Gat the cell's modulation — found the hard way when a 135 B identity ADVERT (~670 ms at SF9/BW125) exceeded an entire 4-slot superframe's CW and silently never delivered. Long contention frames (identity ADVERTs) SHOULD additionally be restricted to the early contention micro-slots so their tails stay inside the receiver's window.
A slot's width MUST be ≥ the airtime of the largest frame that may be granted into it, plus
G. Sizing a slot to one fixed (telemetry) payload is non-conformant: an oversized frame then
overruns into the next member's slot, and the coordinator silently drops both the message and its
delivery-ACK. This is exactly the class of failure that bug #60 produced in the field — a ≥56 B
message frame (~370 ms+ at SF9) overran a 300 ms telemetry-sized slot, so every cross-cell message
was lost while telemetry (35 B / ~280 ms) fit and masked it.
The as-built US915/SF9-BW125 fleet therefore distinguishes two slot classes and pins these constants
(firmware client/src/main.cpp, client/src/loomwave_msg.h; infra infra/crates/infrad/src/main.rs):
| constant | value | meaning |
|---|---|---|
SLOT_W (message slot) |
900 ms | holds a 162 B message frame (~874 ms ToA @ SF9) + guard |
ENV_MAX_BODY |
112 B | max message body → 162 B frame → ≤ SLOT_W |
FRAME_T |
10 400 ms | fits beacon + member slots + a message-sized downlink + the ~750 ms identity ADVERT, and leaves a backbone relay (role B, beacon staggered by FRAME_T/2) a full half-frame |
Implementations MUST enforce the invariant at compile time, not by convention: the firmware and
the Rust infra both carry static_assert/const _: () = assert!(…) guards that
ToA(MAX_DATA_FRAME) ≤ SLOT_W and that the per-half frame budget (beacon + slots + downlink + advert)
≤ FRAME_T/2. A shared lora_toa_ms_sf9() is unit-tested to cross-validate the sim, firmware, and
infra airtime numbers against one another (see sim/tests/test_slot_fit.py, bug #61).
The BEACON is the time reference; GPS is an enhancement, never required (core goal #4).
t0 + k·(slot_width).±(ppm · FRAME_T). The guard interval
MUST satisfy G ≥ 2·(ppm_max · FRAME_T) + T_prop_max + T_rx_settle. With commodity ±20 ppm
crystals and LoRa propagation ≤ ~0.5 ms even at 150 km, the drift term dominates: even at the
as-built FRAME_T = 10 400 ms (§2.1) it is 2·20e-6·10 400 ms ≈ 0.42 ms, so a guard of ~3 ms
remains sufficient — the value used in sim-result-v3 to achieve 98–99% efficiency. (The client
re-anchors on every beacon, so the budget is one inter-beacon interval, not free-running drift.) A
coordinator MAY shrink FRAME_T to tighten the guard where clients report poor holdover, but
MUST NOT shrink it below the §2.1 slot-fit floor.BEACON_MISS_MAX superframes (default 3) MUST
consider the coordinator lost and enter the fallback regime (§6).Rationale. Slot discipline needs only relative alignment within one superframe, re-anchored every beacon — so absolute time (GPS) is unnecessary. The guard absorbs one inter-beacon drift, not unbounded free-running drift.
P=1 frame except in a slot whose SLOTMAP
entry equals its own SRC_ADDR, or in an open slot (§4.2). Within its slot it MAY send one
frame whose total airtime ≤ slot_width − G. A node MUST NOT emit a DATA body larger than
ENV_MAX_BODY (112 B as-built, §2.1); a larger body would exceed SLOT_W and overrun the next
slot (bug #60). A message-class slot (SLOT_W) is granted on demand; standing telemetry uses the
smaller polled pool (PACT-lite; see docs/membership-scaling.md in the firmware repo).A=1 (packet-format.md §3) is acknowledged by the
DST owner in its own next owned slot (not inline), carrying a CONTROL-class ACK. Inline
RTS/CTS is forbidden (its airtime cost was rejected in mac-layer-options.md). Retransmission
policy (max attempts, backoff across superframes) is set per TC: EMERGENCY/INTERACTIVE retry
promptly, BULK retries lazily.packet-format.md §8.2) in the contention
window; the coordinator grants it in a subsequent SLOTMAP. Until granted, a node uses only the
contention plane.The coordinator MAY assign the same slot index to two owners whose cells do not overlap, so
they transmit concurrently — the spatial-TDMA capacity mechanism. sim-result-v4 showed this is
robust to imperfect separation because LoRa capture lets the stronger signal survive partial
overlap; reuse is clean once owners are separated by ≳ 1.5× the cell range. The coordinator is the
sole authority for reuse assignment (it holds the topology, routing-spec.md); a client never
self-selects reuse.
0x000000 SLOTMAP entries are open slots: any node MAY contend in them using the §5.2
epoch-hash rule (not CSMA). They give the schedule elasticity for bursty load without a REGISTER
round-trip.
The contention window (and open slots) carry: BEACON-independent discovery, ADVERTs, REGISTERs, and best-effort/un-slotted DATA (e.g. a node that has not yet registered).
All coordinator-originated traffic other than the BEACON itself (identity ADVERTs, LSAs, coordinator-claims) MUST be transmitted through the contention window like any other node's contention traffic — never in the scheduled region. (Stage-4 field bug: a coordinator advert sent immediately after the beacon transmitted straight through a client's slot 0, producing a perfect every-Nth-frame loss signature.) Implementations with blocking receive paths MUST chunk RX so the coordinator's own scheduled transmissions fire on time.
A node MAY perform a brief listen-before-talk as a politeness optimization where it can hear
peers, but the MAC MUST NOT depend on it for correctness, because hidden senders defeat it
(sim-result-v1). Collision avoidance in this plane comes from epoch-hash slotting (§5.2), not
sensing.
The contention window is divided into C micro-slots. A node wishing to send chooses its micro-slot:
slot = H( SRC_ADDR ‖ EPOCH ‖ attempt ) mod C
where EPOCH is the current beacon epoch (or the fallback epoch, §6) and attempt increments on each
retry. Because the hash spreads independent senders across slots without requiring them to hear each
other, it cuts the birthday-collision rate sharply below slotted-ALOHA-by-random-choice and far
below CSMA at a hidden-node hub (sim-result-v6). On collision, a node increments attempt and
re-hashes (randomized exponential-style backoff bounded by TC).
The coordinator MUST keep C·micro_slot ≥ CW_FLOOR so unregistered/new nodes always have entry
capacity; CW_FLOOR scales with observed join rate.
When no BEACON is heard (cold start, or coordinator lost per §3), nodes operate entirely in the contention plane using an epoch-hash schedule keyed by a shared time-free epoch:
EPOCH is derived from a coarse shared counter (e.g. a hash chain
seeded at network genesis, advanced by elapsed superframe estimate) — never a fixed constant.
sim-result-v6 is explicit: static-hash slotting collapses (everyone collides identically every
round) and is forbidden; the epoch MUST rotate so the slot permutation changes each round,
which is what keeps fallback strictly above the CSMA floor at every K.P=0; slot selection is §5.2 with the fallback epoch. Delivery
degrades gracefully with load (the v6 curve) but never below CSMA and never collapses.routing-spec.md), since no INFRA link-state
exists.Switching between scheduled and fallback regimes is the MAC's graceful-degradation guarantee. One
frame structure spans both (a superframe with an empty SLOTMAP is a pure-contention frame), so
the transition is continuous, not a re-association.
BEACON_MISS_MAX misses (§3) a node stops honoring the
stale SLOTMAP, adopts the fallback epoch, and continues on the contention plane. Nodes converge
because the fallback epoch is derived from shared state, not from the lost beacon.packet-format.md), a node immediately re-anchors time (§3), ceases fallback slotting, and resumes
scheduled behaviour; if it had data flowing it REGISTERs for a slot.SIG + the highest-vantage
coordinator-claim (routing-spec.md election) breaks ties; a client MUST follow the coordinator
whose cell it hears best per its LINK_REPORTS. Overlapping coordinators coordinate slot ranges via
the INFRA link-state plane (routing-spec.md) so their schedules do not collide — the
globally-coordinated schedule that fixed the naive per-cell-overlap collisions found during v3
development.Per design-decision.md §5 and sim-result-v4:
(SF, BW) (announced in CELL_DESC) as the fastest modulation
the cell's clients' link budgets allow, using the SNR LINK_REPORTS from REGISTER frames. Faster
SF → shorter slots → more slots per superframe → more capacity (v4: ×16 at 50 nodes; the 500-node
single-domain ceiling of 1 msg/4 min dissolves to 8 msg/min, ×34).design-decision.md §5, node-roles-spec.md) MAY run a robust
long-reach plane and a fast high-capacity plane concurrently on separate radios; each radio is its
own cell with its own CELL_DESC.This is the normative form of the Checkpoint-B rule (design-decision.md §5) that structurally
prevents the Meshtastic downlink_enabled congestion failure.
TC = BACKHAUL_INJECT (packet-format.md §5) by the bridging INFRA node.BH_CAP of the cell's airtime budget (default ≤ 10%), and
MUST be the first traffic shed when the cell approaches its utilization ceiling (§10).DST is a locally-registered address, plus channel broadcasts explicitly
configured for local relay.)sim/test).sim-result-v1 shows flood-class systems begin to fail) it MUST
prioritize by TC: admit EMERGENCY/CONTROL/INTERACTIVE first, defer TELEMETRY, shed
BULK, shed BACKHAUL_INJECT first of all (§9).v3/v5 measured it
< 15% with rate-capped beacons and O(N) registration cadence.A client that hears two cells (a cell boundary) is the chief open MAC item from design-decision.md
§5/§8. v1 resolution: the client associates with the cell it hears at the higher link margin
(per its LINK_REPORTS), and the two coordinators, aware of the shared client via the INFRA
link-state plane, MUST NOT assign it overlapping obligations. A client MUST NOT transmit
scheduled frames into more than one cell in the same superframe. Exact hysteresis thresholds (to avoid
ping-pong) are to be tuned in a follow-up sim run before firmware; flagged for Checkpoint C discussion.
A conformant node MUST: (1) transmit P=1 only in an owned/open slot under a valid current
schedule (§4); (2) never depend on carrier sense for correctness (§5.1); (3) use epoch-hash slotting,
never static-hash, in contention/fallback (§5.2, §6); (4) enter fallback after BEACON_MISS_MAX
misses and rejoin on a valid BEACON (§7); (5) transmit at the announced cell modulation when scheduled
(§8); (6) cap and schedule (never raw-flood) backhaul-injected traffic (§9); (7) shed by TC above the
utilization ceiling (§10). A coordinator additionally MUST keep the superframe within FRAME_T,
maintain the contention-window floor, and own all reuse/modulation/admission decisions.
Builds on packet-format.md. Pairs with routing-spec.md (where schedules/topology come from) and
security-spec.md (beacon signatures, epoch keying). Reviewed at Checkpoint C before firmware.