← RFC

Loomwave Protocol Specification — Reliable Messaging & Transport

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_envelope call sites, arq_tick, handle_msg_envelope, the downlink queue), and infra/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 (notably ENV_MAX_BODY), the shipped code is authoritative and is what this spec states.


1. Scope

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).

2. Two crypto layers (why a coordinator routes but cannot read)

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.

3. The ENV_TEXT envelope

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)

Envelope fixed overhead = ENV_HDR (25) + ENV_TAG (8) = 33 B on 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.

4. ARQ — confirmed delivery

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).

4.1 Ack request and delivery receipt

4.2 Retransmission

arq_tick runs each loop over sentq:

4.3 Idempotence (dedupe by msg_id)

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.

6. Routing a message to its destination

The inner envelope is unchanged on every hop; only the outer seal is re-applied:

7. Conformance checklist

A conformant messaging implementation MUST:

  1. Carry text in the §3 envelope (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.
  2. Cap a single-frame body at ENV_MAX_BODY = 112 B; longer text sets MORE and fragments (deferred).
  3. On receiving a TEXT with ACK_REQ, auto-emit an ENV_RECEIPT carrying the acked MSG_ID back to SRC24, at TC = INTERACTIVE.
  4. Retransmit an un-acked ACK_REQ message on the ARQ_RETRY_MS/ARQ_MAX_RETRIES schedule, re-applying the outer seal at the live epoch, then report failed.
  5. Dedupe delivery by (peer, msg_id); re-ack (but never re-deliver) a duplicate.
  6. Transmit every coordinator→client downlink inside the client's post-slot RX window (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.