Stage 3 / Checkpoint C deliverable. Status: Draft normative spec. Date: 2026-06-09
(wire v2 correction 2026-07-02). Supersedes: the byte-level sketch in
design-decision.md §7 — this document freezes it.
Wire-v2 hand-correction (2026-07-02, issue #105). Sections 3, 5, 8.1, and 10 were hand-corrected to match the shipped wire v2 (
VER = 0b010) — the fleet runs v2, but this published spec previously documented v1 (0b001). The authoritative source is the Rust crateinfra/crates/proto/src/lib.rs(pub const VERSION = 0b010) and its firmware mirrorclient/src/loomwave_proto.h(constexpr uint8_t VERSION = 0b010); the field widths and byte order below were transcribed from thoseencode/decodepaths. This correction supersedes the older auto-generated mirror of this file (the generator pulls from a private repo that is no longer reachable, so the correction is applied by hand and will not be overwritten). Reliable messaging / transport (ENV_TEXT + ARQ + downlink RX-window timing) is specified in the companionreliable-messaging.md.Normative language. The key words MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, MAY, and OPTIONAL are to be interpreted as in RFC 2119/RFC 8174. Byte and bit numbering: byte 0 is first on the wire; within a byte, bit 7 is most-significant. Multi-byte integers are big-endian (network order) unless stated. This document defines the wire contract every Loomwave node MUST implement; MAC/routing/security behaviour is specified in the companion docs (
mac-spec.md,routing-spec.md,security-spec.md,node-roles-spec.md), which reference the fields defined here.
A Loomwave frame is carried in a single LoRa PHY payload. The format is engineered against three hard constraints established in Stage 1/2:
phy.py, Semtech ToA). The format therefore spends zero bytes on anything derivable from
context (the AEAD nonce is computed, not transmitted — §7) and packs sub-byte fields.SEQ, EPOCH)
MUST be meaningful without GPS or wall-clock agreement (security-spec.md).This document is the contract between independent implementations: a frame built per this spec MUST be decodable by any conformant node, and a node MUST reject frames that violate the structural rules in §9.
Loomwave defines four frame types, distinguished by TYPE (§3, byte 0 bits [1:0] in control
frames; data frames are TYPE=0):
| TYPE | Name | Direction | Plane | Purpose |
|---|---|---|---|---|
| 0 | DATA | any → any | scheduled or contention | user/application payload (the common case) |
| 1 | BEACON | INFRA → all | scheduled | TDMA schedule + cell modulation descriptor + time anchor |
| 2 | REGISTER | client → INFRA | scheduled (unicast) | slot/capability request, link reports |
| 3 | ADVERT | any → all | contention | neighbour/route/identity advertisement |
DATA is specified in full first (§4) because it is ~95% of frames and defines the shared header all types reuse (§3).
Every Loomwave frame begins with a 2-byte common header:
byte 0 VER_FLAGS
┌───────────────────────────────────────────────────────────┐
│ 7 6 5 │ 4 3 2 │ 1 │ 0 │ │
│ VER │ TC │ P │ A │ │
└───────────────────────────────────────────────────────────┘
[7:5] VER protocol version (this spec = 0b010 = v2; v1 = 0b001 is retired)
[4:2] TC traffic class (§5)
[1] P plane: 0 = contention, 1 = scheduled
[0] A ack_req: 1 = unicast ACK requested
byte 1 HOPS / TYPE
┌───────────────────────────────────────────────────────────┐
│ 7 6 5 4 │ 3 2 │ 1 0 │ │
│ HOPLIM │ HUSE│ TYPE│ │
└───────────────────────────────────────────────────────────┘
[7:4] HOPLIM hop limit / TTL (0–15; decremented per relay)
[3:2] HUSE hops used (0–3, saturating; diagnostics + loop hint)
[1:0] TYPE 0=DATA 1=BEACON 2=REGISTER 3=ADVERT
VER (3 bits) gates wire evolution; a node MUST discard a frame whose VER it does not
implement (no partial parse — CommonHeader::decode returns BadVersion and rejects). The shipped
fleet emits and requires VER = 0b010 (v2). v2 adds the PACT-lite membership-scaling beacon
block (§8.1) — the rotating telemetry roster, coordinator-polled demand, and the TIM downlink-pending
bitmap — to the v1 beacon. A beacon-format change is a coordinated reflash: v1 and v2 nodes MUST
NOT share a cell (a v1 beacon lacks the PACT block and is silently dropped by v2 clients, and vice
versa). The retired v1 = 0b001 is documented here only for the version gate; values 0b011–
0b111 are reserved for future versions.TYPE (2 bits) moves into the common header (vs. the §7 sketch, which implied it from context)
so a receiver can dispatch before decrypting. Cost: 0 extra bytes (reclaimed from HOPS).HOPLIM/HUSE: a relay MUST drop a frame when HOPLIM == 0, and otherwise MUST
decrement HOPLIM and saturating-increment HUSE before retransmit. HUSE saturates at 3 (2 bits)
— it is a diagnostic/loop hint, not a guarantee; HOPLIM is the authoritative TTL.Rationale — why TYPE in the clear. The receiver must route BEACON/REGISTER without first running AEAD (a BEACON is what carries the keying epoch context). Exposing 2 bits of type leaks nothing an observer cannot infer from timing/length and saves a decrypt-then-discard on every control frame.
off field size notes
0 VER_FLAGS 1 common header (§3); TYPE=0
1 HOPS 1 common header (§3)
2 SRC_ADDR 3 24-bit truncated self-certifying address (routing-spec §)
5 DST_ADDR 3 24-bit; 0x000000 = broadcast/channel
8 SEQ 1 per-(src,epoch) sequence number; feeds replay + nonce (§7)
9 PAYLOAD 0–239 AEAD ciphertext (application data; §6 plaintext layout)
… AEAD_TAG 8 truncated AEAD tag over header+payload; nonce derived (§7)
PAYLOAD = 255 − 16 = 239 bytes.
This matches the budget asserted in security-options.md and design-decision.md §7.SRC_ADDR/DST_ADDR are 24-bit truncations of the node's self-certifying identity
(security-spec.md §). 24 bits → 16.7 M space; birthday-collision risk is managed by the
collision-checked address-claim procedure in routing-spec.md (a claimant detecting a clash
re-derives). DST_ADDR = 0x000000 is the reserved broadcast/channel address and MUST NOT be
claimed by any node.SEQ is a 1-byte counter scoped to (SRC_ADDR, EPOCH). It is the replay window index and a
nonce input (§7). Wrap handling and window width are normative in security-spec.md; the format
guarantees only the field. The current EPOCH is not carried in DATA frames — it is established
by the most recent BEACON (§5 of security-spec.md), keeping DATA overhead at 16 B.The 9-byte header is transmitted in the clear but is included as AEAD associated data (AAD), so
any bit-flip in VER_FLAGS, HOPS, SRC, DST, or SEQ fails tag verification. PAYLOAD is the
only encrypted region. Relays therefore forward on cleartext header fields (they need HOPS/DST to
route) while end-to-end integrity still covers the whole frame. (security-spec.md §).
TC, 3 bits)TC is set by the originator and MUST be preserved unchanged by relays. It costs 0 airtime
(packed in byte 0) and drives MAC scheduling priority (mac-spec.md) and backhaul admission
(mac-spec.md §, the downlink-governance rule).
TC |
Name | Use | Scheduling intent |
|---|---|---|---|
| 0 | EMERGENCY |
life-safety / SOS | highest; pre-emptive slot grant |
| 1 | CONTROL |
beacons-relayed, route/ACK control | high; protocol must not starve |
| 2 | INTERACTIVE |
direct messages, acks of user msgs | above bulk; latency-sensitive |
| 3 | TELEMETRY |
periodic position/metrics | default for sensors |
| 4 | BULK |
large/low-priority, store-forward catch-up | lowest local priority |
| 5 | BACKHAUL_INJECT |
traffic sourced from an IP/MQTT bridge onto RF | capped + deprioritized (§) |
| 6–7 | reserved | — | MUST NOT be emitted; receivers treat as TELEMETRY (TrafficClass::from_bits) |
Rationale — a dedicated
BACKHAUL_INJECTclass. Per the Checkpoint-B decision (design-decision.md§5), backhaul→RF traffic is admission-controlled and budget-capped. Giving it an explicit class lets the INFRA scheduler cap and deprioritize it by inspection of the cleartext header — no decrypt required — which is what structurally prevents the Meshtasticdownlink_enabledcongestion failure. Reserving 2 values (6–7) leaves room without a version bump.Traffic-class use in messaging. Reliable user messaging (
reliable-messaging.md) rides DATA frames; delivery receipts/acks are emitted atTC = INTERACTIVEso they are not starved byBULK/TELEMETRY, and a coordinator sourcing traffic from an IP bridge onto RF usesBACKHAUL_INJECT(also the class it uses to re-inject a client→client downlink).
After AEAD decryption, PAYLOAD begins with a 1-byte PORT discriminator (application/port number,
Meshtastic-compatible numbering where practical) followed by port-specific bytes. The MAC/routing
layers are payload-agnostic; PORT is defined by the application layer (out of scope here, noted so
implementers reserve byte 0 of plaintext). Max application bytes = 239 − 1 (PORT) = 238 B.
The AEAD nonce is never transmitted. Both ends compute:
nonce = TRUNC_12( H( SRC_ADDR ‖ SEQ ‖ EPOCH ‖ DIR ) )
where H is the KDF/hash named in security-spec.md, EPOCH is the current keying epoch (from the
latest BEACON), DIR is a 1-bit direction tag (0 = from address-owner, 1 = toward) preventing
two-party nonce reuse, and TRUNC_12 takes the low 12 bytes (96-bit nonce). Because SRC, SEQ,
EPOCH are all either on the wire (SRC, SEQ) or shared state (EPOCH), the receiver reconstructs the
nonce with 0 dedicated bytes. Reuse-safety (the SEQ-wrap / EPOCH-rotation rule) is normative in
security-spec.md.
The coordinator's periodic frame; carries the schedule, the cell's modulation descriptor, the time
anchor, and the keying epoch. Variable length; fields after the common header (from
proto::Beacon::encode/decode):
off field size notes
2 SRC_ADDR 3 coordinator address
5 EPOCH 4 monotonic keying/schedule epoch (nonce + replay anchor; big-endian)
9 CELL_DESC 3 byte0 [7:3]=SF [2:0]=BW ; bytes 1-2 = freq_offset (u16 BE, channel plan)
12 FRAME_T 2 u16 BE. low 14 bits = superframe length ms; bit15 = PHY-change flag;
bit14 = per-link mixed-SF flag (see below)
14 [PHY_CHANGE] 4 PRESENT iff FRAME_T bit15 set: announced (SF,BW,freq,frames) — see §8.1.1
F SLOTMAP_LEN 1 N = number of telemetry slot entries that follow (F = 14, or 18 if PHY_CHANGE)
F+1 SLOTMAP N×3 per-entry: slot_owner_addr(24b BE); index = slot #; 0x000000 = open slot
… NEXT_PTR 1 next free/contention-window pointer
… N_GRANTS 1 count of on-demand message-slot grants this frame (#63); 0 = telemetry-only
… GRANTS Ng×3 granted member addr24 (BE), in message-slot order
… PACT_BLOCK var PACT-lite membership block — v2 only (§8.1.2); empty block on ESP32 coord
… SIG 64 Ed25519 signature over all preceding bytes (full; security-spec §6 erratum)
SIG, full Ed25519 — see the security-spec §6 erratum on
why truncation is unsound for signatures): it is a broadcast authenticated
to the coordinator identity, so every listener verifies origin without a shared pairwise key
(security-spec.md §). SIG covers all bytes before it (Beacon::signable). Signature cost is
amortized by the rate cap (mac-spec.md; measured control overhead < 15% in sim-result-v3).CELL_DESC makes the INFRA node the modulation/channel authority (design-decision.md §5):
clients adopt the announced (SF, BW, freq) for the cell. Byte 0 packs SF in bits [7:3] (5 bits,
SF 5–12) and the BW code in bits [2:0] (0 = 125, 1 = 250, 2 = 500 kHz); bytes 1–2 are the u16
big-endian channel-plan frequency offset. Offset N ≥ 1 names the absolute channel
900 MHz + N·100 kHz; offset 0 is reserved = "no channel change" (a steady cell keeps 0).FRAME_T is a bit-field, not a plain length. Bit 15 (0x8000) set = a 4-byte
announced-PHY-change block follows FRAME_T (§8.1.1). Bit 14 (0x4000) set = this is a
per-link mixed-SF beacon: each PACT roster entry carries a per-member (SF, BW) (§8.1.2). The two
flags are independent. Both flag bits are masked off before reading the length, so bits [13:0] carry
the superframe length in ms; the shipped FRAME_T = 10400 ms (mac-spec.md §4) fits comfortably in
14 bits.SLOTMAP binds slot index → owning 24-bit address; an empty/0x000000 entry is an open/contention
slot. NEXT_PTR follows the slot map, then the on-demand grant list (N_GRANTS then that many
addr24) — members granted a message slot in the post-telemetry message region, in list order (#62/#63).
Exact slot semantics, guard times, and the no-coordinator epoch-hash fallback schedule are normative
in mac-spec.md.PHY_CHANGE, 4 bytes — present iff FRAME_T bit 15)The coordinator advertises an upcoming switch so members pre-tune instead of blind-rescanning
(#65 DFS / #66 adaptive SF; announced-phy-change-design.md). It sits immediately after
FRAME_T, shifting SLOTMAP_LEN and everything after it by 4 bytes:
off field size notes
+0 TARGET_SF/BW 1 [7:3]=target SF [2:0]=target BW code (same packing as CELL_DESC byte 0)
+1 TARGET_FREQ_OFFSET 2 u16 BE channel-plan offset (0 = SF-only change, leave frequency alone)
+3 FRAMES_UNTIL_SWITCH 1 switch at switch_epoch = EPOCH + FRAMES_UNTIL_SWITCH
PACT_BLOCK — v2 only)The v2 addition that decouples membership from per-frame airtime (membership-scaling.md;
PACT-lite in the paper §5). The coordinator names a small rotating telemetry roster (only rostered
members TX a keepalive this frame — idle members cost no airtime), a poll list of members invited to
signal queued-message demand collision-free, and a per-member TIM downlink-pending bitmap
(802.11ah-style). It is appended after the grant list, before SIG. The ESP32 coordinator path emits an
empty block (all counts 0) so its beacon is still v2-valid; the Rust cell()/backbone() hub is the
canonical PACT producer. Layout (PactBlock::encode_into):
off field size notes
+0 SF_INDEX 1 frame index within the super-frame, 0..R-1
+1 R 1 super-frame length R = ceil(K / P) in frames (P = telemetry-pool slots)
+2 N_ROSTER 1 number of roster entries that follow
+3 ROSTER Nr×E per entry: owner_addr(24b BE) ‖ SLOT(1). E = 4 per-cell;
E = 6 in per-link mode (+SF byte +BW-code byte per entry — FRAME_T bit14)
… N_POLL 1 number of polled members that follow
… POLL Np×3 addr24 (BE) invited to signal demand this frame
… N_TIM 1 number of TIM bitmap bytes that follow ( = ceil(K/8) )
… TIM Nt×1 downlink-pending bitmap: bit i (of byte i/8, LSB-first) = pending for
member table position i → tim[i/8] & (1 << (i%8))
SLOT (0..P-1) that frame. In per-link mixed-SF mode (FRAME_T
bit 14) each entry grows to 6 bytes with a per-member SF byte (bits [7:3]|[2:0] packing) and a BW-code
byte, so each member's slot runs at its own (SF, BW); the beacon's own CELL_DESC.SF is then the
slowest member's SF (#66 Phase 2 / #77). In per-cell mode the SF/BW bytes are absent and every slot
runs at the cell SF.GRANTS
path. See §8.1.3.reliable-messaging.md §3). N_TIM = ceil(K/8) bytes covers K members.PactBlock::decode_at bounds-checks each length prefix (N_ROSTER,
N_POLL, N_TIM) against the remaining buffer and rejects a truncated block with no partial parse;
the firmware mirror additionally caps parsed counts at PACT_MAX_ROSTER/POLL/TIM = 8/8/16.The dedicated request slot that lifts the PACT-lite first-message-latency breakpoint past any realistic
hub (deviation2-request-slot.md) adds no new beacon field. Poll-naming is cheap (3 B per
name in POLL); reserved request airtime is not — so the coordinator names many members for demand
each frame (POLL_TOTAL = 16) but reserves only a small fixed request window (REQ_WINDOW = 2
micro-slots), whose geometry is derived on both sides from the beacon's POLL list plus the compiled-in
REQ_WINDOW/REQ_W constants. A polled member with a queued message transmits a ~16-byte request in a
randomly chosen micro-slot; the coordinator then grants it a message slot the next frame via the existing
GRANTS list. The request path is an additive fast-path over the guaranteed roster-rotation fallback.
off field size notes
2 SRC_ADDR 3 registering client
5 DST_ADDR 3 target coordinator
8 SEQ 1 replay (as DATA)
9 CAP_FLAGS 1 role/capability bits (relay-capable, multi-radio, power class…)
10 REQ_SLOT 1 requested slot / 0xFF = "assign me"
11 LINK_REPORTS M×4 per-INFRA: [31:8] peer_addr(24b) [7:0] snr_q (link budget report)
… AEAD_TAG 8 sealed to the coordinator (unicast)
LINK_REPORTS feed the modulation-authority and coordinator-selection decisions
(routing-spec.md): the client tells INFRA the SNR at which it hears each coordinator, so INFRA can
pick the cell SF and so the network can elect coordinators by measured link budget, not role label
(the Checkpoint-B elevated-nodes.md finding). snr_q is SNR quantized per security-spec.md/PHY. off field size notes
2 SRC_ADDR 3 advertiser
8 SEQ 1 replay
9 ADV_FLAGS 1 advert subtype (identity / neighbour / route / coordinator-claim)
10 ADV_BODY var subtype-specific (e.g. coordinator-claim carries vantage/altitude proof)
… SIG 64 Ed25519, full (adverts are signed, not sealed; security-spec §6 erratum)
A coordinator-claim advert (subtype in ADV_FLAGS) is how a node asserts INFRA candidacy; its
ADV_BODY carries the vantage/link-budget evidence the election in routing-spec.md evaluates. This
is the wire hook for "coordinator membership by measured vantage, not config role."
A conformant receiver MUST, in order, before delivering or relaying any frame:
VER ≠ an implemented version.TYPE; reject if the frame is shorter than that type's fixed minimum (DATA ≥ 17 B:
9 header + 0 payload + 8 tag).SIG against the claimed SRC identity; reject on failure.(SRC, SEQ) falls outside the sliding window for the current EPOCH
(security-spec.md).HOPLIM, DST) apply only to frames that pass 1–6.A node MUST NOT act on, forward, or surface to the application any frame failing 1–6.
| Frame | Fixed overhead | Max payload | Notes |
|---|---|---|---|
| DATA | 16 B (9 hdr + 8 tag, −1 PORT in plaintext) | 238 B app | the common case |
| BEACON (v2) | 86 B fixed (2 hdr + 3 src + 4 epoch + 3 cell + 2 frame_t + 1 slotmap_len + 1 next_ptr + 1 n_grants + 5 empty-PACT + 64 sig) + 3·slots + 3·grants + PACT entries + [4 if PHY-change] | n/a | rate-capped; signed (full Ed25519); PACT block is v2-only |
| REGISTER | 19 B + 4·(link reports) | n/a | unicast, sealed; REQ_SLOT 0xFF = "assign me" |
| ADVERT | 8 B + body + 64 sig | n/a | signed (identity/LSA/coord-claim/neighbors/time subtypes) |
The empty PACT block is 5 bytes —
SF_INDEX(1) ‖ R(1) ‖ N_ROSTER=0(1) ‖ N_POLL=0(1) ‖ N_TIM=0(1)(PactBlock::wire_lenwith all lists empty). Each populated roster entry adds 4 B (6 B per-link), each poll adds 3 B, and the TIM bitmap addsceil(K/8)B.
design-decision.md §8)VER-gated parameter if revisited.BACKHAUL_INJECT class that makes downlink governance inspectable without decryption.TYPE promoted into the clear (2 bits, 0 byte cost) to dispatch control frames pre-decrypt.The wire format is frozen above. These behaviours reference these fields but are specified elsewhere:
SEQ-wrap / EPOCH-rotation rule and replay window width (security-spec.md); slot/guard semantics and
fallback schedule (mac-spec.md); address-claim collision procedure and coordinator election scoring
(routing-spec.md); boundary-client cell association (mac-spec.md/[routing-spec.md`](routing-spec.md)).
This is the keystone Stage-3 document; mac-spec.md, routing-spec.md, security-spec.md, and
node-roles-spec.md build on the fields defined here. Reviewed together at Checkpoint C before any
firmware.