Stage 4 deliverable (the messaging headline). Status: Draft normative spec, hand-corrected to
the shipped firmware. Date: 2026-07-02 (issue #105).
Builds on: packet-format.md (DATA frame + traffic classes),
security-spec.md (AEAD, TOFU identities), mac-spec.md (superframe/slot timing).
Hand-correction note (2026-07-02, issue #105). This section was written by hand from the shipped firmware and infra code, not regenerated from the (unreachable) private mirror. The authoritative sources are
client/src/loomwave_msg.h(the ENV_TEXT envelope + ARQ flags +ENV_MAX_BODY),client/src/main.cpp(build_envelope/open_envelopecall sites,arq_tick,handle_msg_envelope, the downlink queue), andinfra/crates/infrad/src/main.rs(cell()/backbone()downlink scheduling). Field widths and constants below were transcribed from those files. The companion design rationale lives in../docs/messaging-design.md; where that design doc and the shipped code disagree (notablyENV_MAX_BODY), the shipped code is authoritative and is what this spec states.
packet-format.md freezes the frame contract; this document specifies the transport that rides
inside a DATA frame to give users reliable end-to-end text messaging: the ENV_TEXT envelope, the ARQ
(ack-request + delivery-receipt + retransmission) that turns best-effort delivery into confirmed
delivery, and the downlink RX-window timing a coordinator MUST honour for a downlink to reach a
scheduled client at all. Group/broadcast channels and the phone↔node BLE framing are out of scope here
(see ../docs/messaging-design.md §5–§6).
Every DATA frame is already sealed with the outer client↔coordinator pair key
(X25519(client_x, coord_x)); the coordinator decrypts that to read SRC/DST/TC and route,
re-sealing to the next hop. Messaging adds a second, inner, endpoint-to-endpoint seal so the
coordinator cannot read user text:
USER TEXT
└─ INNER seal: k_e2e = HKDF(X25519(src_x, dst_x), salt="loomwave/v1/e2e", info=msg_id‖DIR)
→ the "message envelope" (§3), sealed with ChaChaPoly (8-byte truncated tag)
└─ OUTER seal: existing client↔coordinator pair key
→ rides as the DATA-frame PAYLOAD the coordinator routes but cannot open
The inner key needs no handshake: both endpoints' x_pub are already on the wire in their signed
identity ADVERTs (packet-format.md §8.3), cached via TOFU. The inner key is keyed per-message
(msg_id), not per-epoch, so an envelope survives store-and-forward across epochs and cross-cell hops
unchanged (security-spec.md; ../docs/messaging-design.md §2.4). Coordinators see the
whole envelope as opaque payload bytes on every hop.
The envelope is the plaintext of the DATA payload. packet-format.md §6 reserves plaintext byte 0 as a
PORT discriminator; messaging claims PORT_MSG = 0x10. Multi-byte integers are big-endian, matching
the frame wire format. Layout (build_envelope/open_envelope, loomwave_msg.h):
off field size notes
0 PORT 1 = 0x10 (PORT_MSG); claims the messaging port
1 ENV_TYPE 1 0x01 TEXT · 0x02 ACK · 0x03 RECEIPT · 0x04 NAME · 0x05 BCAST
2 ENV_VER 1 = 0x01
3 FLAGS 1 bit0 = ACK_REQ (0x01) · bit1 = MORE (0x02) · bit2 = BCAST (0x04)
4 MSG_ID 8 sender-random 64-bit id; dedupe key + inner-KDF info
12 SRC24 3 originating endpoint node ADDR24 (BE)
15 DST24 3 destination endpoint node ADDR24 (BE); 0xFFFFFF = broadcast/channel
18 SEQ 1 fragment index (0 for single-frame); also feeds the inner nonce
19 TS 4 sender unix-seconds (u32 BE; advisory display time, NOT keyed/replayed)
23 BODY_LEN 2 u16 BE length of SEALED_BODY
25 SEALED_BODY var ChaChaPoly ciphertext of the UTF-8 text (inner E2E seal)
.. ENV_TAG 8 inner truncated ChaChaPoly tag over (AAD = bytes [0..25), body)
ENV_HDR = 25 (PORT..BODY_LEN inclusive) is both the fixed header size and the AEAD AAD length:
the type, flags, msg-id, src, dst, seq, ts, and body length are authenticated end-to-end but not
encrypted, so a coordinator can read msg-id/src/dst for dedupe/routing yet cannot read or forge the
body without detection.ENV_TAG = 8 — the same 8-byte truncated-tag discipline as the rest of the stack (packet-format.md
§11).ENV_MAX_BODY = 112 (bytes of UTF-8). This is the shipped value,
lowered from 206 by bug #60: the on-air DATA frame carrying a sealed message
(9 B DATA hdr + ENV_HDR 25 + body + ENV_TAG 8 + 8 B outer tag) must fit a member slot's time-on-air.
At SF9 a 112 B body ⇒ a 162 B frame ⇒ ~874 ms, inside SLOT_W_MS = 900 ms (enforced by the
MAX_DATA_FRAME_B slot-fit static_assert in the firmware). Messages longer than 112 B must fragment
(FLAGS bit MORE, using SEQ) — fragmentation is not yet implemented; a dedicated wider data
window would restore the 206 B single-frame ceiling.Envelope fixed overhead =
ENV_HDR (25) + ENV_TAG (8) = 33 Bon top of the DATA frame's 16 B, so a single-frame message costs 49 B of frame overhead and carries up to 112 B of text today.
Messaging turns the frame layer's best-effort delivery into confirmed delivery with an ack-request /
delivery-receipt / retransmission loop (issue #82; arq_tick + handle_msg_envelope in main.cpp).
FLAGS bit ACK_REQ (0x01)** on the outbound ENV_TEXT and
keeps a copy of the inner envelope in a **sent-message table** (sentq) keyed byMSG_ID`.ACK_REQ set, the recipient node auto-emits a delivery receipt:
an ENV_TYPE = ENV_RECEIPT (0x03) envelope, addressed back to the original SRC24, carrying the
acked original MSG_ID (8 bytes) as its SEALED_BODY and minting a fresh MSG_ID of its own. The
receipt is emitted whether or not the message was a duplicate (§4.3) so a lost receipt still resolves
the sender.ENV_RECEIPT by comparing its 8-byte body against sentq entries; on a
match it reports the message delivered (MSG_STATE = 2 to the phone) and clears the entry.
ENV_RECEIPT is the node-side delivery receipt (the node opened the message); an app-level read
receipt is out of scope. Receipts/acks are sent at TC = INTERACTIVE so they are not starved by
bulk/telemetry (packet-format.md §5).arq_tick runs each loop over sentq:
ACK_REQ message whose receipt has not returned within ARQ_RETRY_MS = 45000 ms is
re-queued for transmit (the kept inner envelope is copied to a fresh outbound slot; the outer seal
is re-applied at the live epoch on TX, so a retransmit after an epoch change is still valid).ARQ_MAX_RETRIES = 4; after that the message is reported failed
(MSG_STATE = 3) and the entry is cleared.ARQ_RETRY_MS is set to exceed the full ACK round-trip: message downlink + ack uplink + ack
downlink are each frame-paced (~3–4 cell frames at ~10.4 s ⇒ ~30–40 s measured), so 45 s (≈ 4+ frames)
avoids firing a resend before the receipt could plausibly return. Both constants are build-time
overridable (LOOMWAVE_ARQ_RETRY_MS, LOOMWAVE_ARQ_MAX_RETRIES).Retransmission and store-and-forward re-delivery can deliver the same MSG_ID more than once, so
delivery is idempotent: the recipient keeps a small seen-(peer, msg_id) ring (32 entries) and
drops duplicates rather than re-delivering to the phone. Crucially, a duplicate that still carries
ACK_REQ is re-acked (a fresh receipt is sent) but not re-delivered — this is what lets a sender
whose original receipt was lost eventually resolve to delivered. Dedupe is by msg_id, not seq, and
applies end-to-end.
A scheduled client is not listening continuously — after its own telemetry/message slot it opens a
single, short downlink RX window, then sleeps/transmits elsewhere. A coordinator downlink (a message
delivered to one of its own members, or a client→client re-injection) MUST be transmitted inside that
window or it is silently lost. This was the root cause of the "one check at home" delivery bug: firing
a downlink at the coordinator's general control instant (ctl_at, ~beacon + 2100 ms) delivered nothing,
because by then the client had stopped listening.
beacon + 1428 ms in the in-cell cell() geometry — 525 ms beacon ToA + 3 ms
guard + 3 × 300 ms telemetry slots (NSLOTS = 3). The coordinator computes the same cw_start
and schedules dl_at there, so the downlink lands exactly where the member is back in RX
(infra/crates/infrad/src/main.rs, dl_at = this_beacon + cw_start_ms).backbone()), the post-slot window is derived the same way but with the widened
message-slot geometry: cw_start = BEACON_TOA(525) + guard(3) + NSLOTS × TELEM_W(320) +
n_grants × MSG_W(900), pushed past any granted message slots so the coordinator never transmits over a
member's slot. Downlinks and the deferred identity advert both fire at this cw_start.CO_DL_PER_FRAME = 1); each
additional queued downlink for that member lands in a subsequent frame where the client is again
reliably in RX. The coordinator's downlink queue is a ring (CO_DL_QDEPTH = 8) because ≥ 2 downlinks
can arrive for the same frame under concurrent traffic.packet-format.md §8.1.2) is the wake signal: a
member whose table-position bit is set knows a downlink is queued and keeps its RX window open for it.The inner envelope is unchanged on every hop; only the outer seal is re-applied:
DST24 = recipient. The
coordinator opens the outer seal, sees an in-cell destination, and re-injects the (still-opaque)
envelope as a BACKHAUL_INJECT DATA downlink sealed to that member's pair key, scheduled at
cw_start (§5). This reuses the same delivery primitive as cross-cell.(src, dst, inner_blob) to the LAN
relay / RF backbone → coordinator B re-seals the inner blob to dst's pair key and downlinks it in
B's RX window. No hop touches the inner envelope — that is the point of endpoints-only E2E.A conformant messaging implementation MUST:
PORT_MSG = 0x10, ENV_VER = 0x01, ENV_HDR = 25, ENV_TAG = 8),
inner-sealed with the endpoint-to-endpoint key; coordinators route it as opaque payload.ENV_MAX_BODY = 112 B; longer text sets MORE and fragments (deferred).ACK_REQ, auto-emit an ENV_RECEIPT carrying the acked MSG_ID back to
SRC24, at TC = INTERACTIVE.ACK_REQ message on the ARQ_RETRY_MS/ARQ_MAX_RETRIES schedule, re-applying
the outer seal at the live epoch, then report failed.(peer, msg_id); re-ack (but never re-deliver) a duplicate.cw_start,
beacon + 1428 ms in-cell), one downlink per member per frame.Companion to packet-format.md §8.1.2 (the v2 PACT/TIM beacon block) and
mac-spec.md (superframe geometry). Design rationale and the phone-app protocol are in
../docs/messaging-design.md.