Skip to content

Wire format (LPP)

This document is normative. The encoder/decoder lives in core/src/packet.cpp and must match this document byte-for-byte. Tests in tests/test_packet.cpp pin the layout.

  • A LoRa frame carries at most 255 bytes. Every header byte is paid for in airtime, which at SF12/125kHz costs ~33 ms per byte. The header must be as small as possible for the common case (a short channel message), while still supporting rare heavyweight operations (registration, key rotation, DCC setup) without inventing a second protocol.
  • No protobufs on the wire. Protobuf varint framing costs 1–2 bytes per field plus schema-evolution baggage we don’t need at L2; we use fixed layouts with a version field and per-type payload structs. (Protobuf remains a fine choice for the host-side telemetry export, where bytes are free.)
  • The same packet bytes flow unchanged over LoRa, TCP tunnels, and serial. A TCP link is just a lossless, fast “virtual lane” — see RADIO.md.
  • Identity is a public key, not a node number. Addresses are truncated key hashes; multiple physical devices holding the same key are the same address. See IDENTITY.md.

All multi-byte integers are little-endian. u8/u16/u24/u32/u64 are unsigned. UID is an 8-byte identity address (see IDENTITY.md §2). Tag8 is an 8-byte authenticator (IDENTITY.md §4).

Every LPP packet starts with:

Off Size Field Meaning
0 1 VT bits 7..6: protocol version (v1 = 0b00, v2 = 0b01); bits 5..0: packet type
1 1 FLAGS see below
2 1 TH (v1) / AC (v2) v1: bits 7..4 TTL remaining, bits 3..0 hops taken. v2: the frame’s remaining airtime credit, a raw u8 unit count — see §The AC byte (v2). The version bits (byte 0) gate which interpretation applies; a receiver never guesses.
3 1 PLEN payload length in bytes (after addressing + path/ERB blocks)
4 4 MSGID origin-generated; random at boot, then incremented. Dedup key = SipHash(SRC ‖ MSGID), see §Dedup

FLAGS:

Bit Name Meaning
0 ACK_REQ receiver (final destination) must send ACK
1 SIGNED_FULL payload ends with full Ed25519 signature (64 B) + key hint
2 FRAG fragmentation header present (§Fragmentation)
3 PATH explicit path block present (§Path block)
4 PRIO priority traffic (admin/ACK/DCC control); routers queue ahead
5 TAGGED packet ends with Tag8 trailer
6 VIA_TCP set on the TCP copy of any packet forwarded onto a TCP link; a packet received from TCP, or received with VIA_TCP already set, MUST NOT be re-emitted on LoRa unless this node is the local LoRa egress for the destination (prevents internet→RF flooding; cleared on the RF egress copy)
7 EXT_ADDR reserved by LPP v1.1 to mean an extended addressing block follows. No v1.1 packet type uses that block yet; transmitters MUST keep this bit 0 and receivers MUST reject same-version frames with it set until the extended layout is specified.

TTL defaults to 7. A node MUST drop packets whose TTL is 0 after decrement and MUST increment hops taken when relaying. The 4-bit fields bound any path to 15 hops, which exceeds any sane LoRa path. This paragraph describes v1 only — v2 deletes TTL/hops-taken entirely; see §The AC byte (v2).

Grouped by addressing layout. Types not listed are reserved.

Group A — channel traffic (addressing: SRC UID + CHAN4 + SEQ3 + RTR2)

Section titled “Group A — channel traffic (addressing: SRC UID + CHAN4 + SEQ3 + RTR2)”
Type Name Payload
0x01 CHANMSG UTF-8 text (IRC PRIVMSG to a channel)
0x02 CHANCTL sub-op byte: JOIN-notify, PART-notify, TOPIC, KICK, MODE — see §CHANCTL
0x03 CHANSYNC backfill request/response (§Recovery)

Addressing block (17 bytes): SRC (8) ‖ CHAN4 (4) ‖ SEQ3 (3) ‖ RTR2 (2).

  • CHAN4 = first 4 bytes of SHA-256 of the canonical channel name — lowercase, without the # and without any router suffix (#Pizza.rt1sha256("pizza")[0..4]).
  • RTR2 = the short id of the sequencing router (the router that stamped SEQ3). The pair (CHAN4,RTR2) is the channel scope; the IRC layer renders it as #pizza.rt1. Federated channels merge multiple scopes — see ROUTING.md §Federation.
  • SEQ3 = 24-bit per-(channel,router) monotone sequence stamped by the sequencing router. 0xFFFFFF from a client means “not yet sequenced” (upstream leg, client → router). Wraps are handled with serial-number arithmetic (RFC 1982) — see §Recovery. Routers persist the next assignable SEQ3 for each owned (CHAN4,RTR2) stream and MUST skip the sentinel on restore or wrap; a reboot must never make the stream jump backwards.

A complete one-line chat message <hdr 8> <addr 17> <text…> <Tag8> costs 33 bytes + text. A 40-character message is 73 bytes on air.

Group B — unicast (addressing: SRC UID + DST UID)

Section titled “Group B — unicast (addressing: SRC UID + DST UID)”
Type Name Payload
0x10 DM UTF-8 text (IRC PRIVMSG to a nick)
0x11 ACK acked MSGID (4) + status (1)
0x12 PING opaque echo bytes (link probing, also IRC CTCP PING transport)
0x13 KEYREQ request full pubkey + current chat-key record for a UID (8)
0x14 KEYRESP IdentityRecord (IDENTITY.md §6)
0x15 DCCCTL DCC session control: OFFER/ACCEPT/LANE_GRANT/RESUME/CLOSE (§DCC)
0x16 DCCDATA DCC stream chunk: stream id (2) + chunk seq (2) + bytes
0x17 LANEGRANT router→client anchor-offload promotion: rtr(2) lane_id(1) preset(1) freq_slot(1) ttl_s(2) (§Lane promotion)

Addressing block: SRC (8) ‖ DST (8).

KEYREQ payload is the requested UID (8). KEYRESP payload is the signed IdentityRecord from IDENTITY.md §6: UID8 IK_pub32 epoch1 CTK8 flags1 nick_len1 nick n_subkeys1 subkeys sig64, where each subkey is keyhint1 subkey_pub32 sig64. The record signature covers every byte before the final sig64; each subkey signature is an IK signature over "lrc device subkey v1" || keyhint1 || subkey_pub32, and decoders reject zero, duplicate, or invalid subkey rows. A node that owns the requested UID answers KEYREQ with a KEYRESP whose SRC is that UID and whose DST is the requester. If the request arrived through FLAGS.PATH, the response uses the reversed path. Duplicate KEYREQ packets may be answered again so a lost KEYRESP does not require a new lookup.

Group C — control plane (addressing varies, mostly SRC only)

Section titled “Group C — control plane (addressing varies, mostly SRC only)”
Type Name Payload
0x20 BEACON router presence + lane plan (RADIO.md §Beacons). Payload carries RTR2 + router UID fingerprint + boot id
0x21 HELLO client → router registration request: full pubkey (32) + capabilities + nonce. SIGNED_FULL
0x22 WELCOME router → client: registration accept, session key material, router boot id, channel digest. SIGNED_FULL
0x23 BYE deregistration (either direction): UTF-8 quit reason, Tag8 when a session key exists
0x24 ROTATE chat-key rotation announcement, SIGNED_FULL by identity key (IDENTITY.md §5)
0x25 CHECKPOINT full-signature audit point (IDENTITY.md §4.3)
0x26 ADMIN signed remote-administration op (USERGUIDE.md §Remote admin)
0x27 TELEM fixed 48-byte telemetry rollup (TELEMETRY.md)
0x28 RTRSYNC router↔router federation: seq vectors, member deltas (ROUTING.md)
0x29 LANEPROBE fixed 8-byte listen-after-talk solicitation (§LANEPROBE)
0x2A MAILBOXSYNC authenticated target-peer standby mailbox reconciliation (§MAILBOXSYNC)
0x2B RACK PROGRESS-ACK freshness-bound control frame, never relayed (§RACK); meaningful only to v2 (credit-routing) suppression, but carries no ERB itself

ROTATE payloads are 10 bytes and are SIGNED_FULL by the sender’s identity key:

new_ctk(8) new_epoch(1) reason(1)

Receivers that already cache the sender’s verified IdentityRecord verify the signature against that record’s IK_pub, require new_epoch to be greater than the effective cached epoch, then update the runtime CTK/epoch overlay. Equal, older, zero, or badly signed epochs are rejected. Local rotate UX, device revocation, durable rotation state, and historical epoch archives are identity-policy work above this payload.

CHECKPOINT payloads are 41 bytes and are SIGNED_FULL by the sender’s identity key:

epoch(1) from_msgid(4) to_msgid(4) rolling_sha256_32

The built node workflow applies this to channel and DM windows whose sender identity record is already cached. Local senders can emit after a configured message count or elapsed time; the default cadence is 32 messages or 15 min. Receivers keep a bounded local checkpoint archive row for every valid signed checkpoint from a cached identity: ok or mismatch when the canonical message window is retained, and unverified when the signed checkpoint is valid but the window is no longer available locally. Archive rows retain the signed checkpoint digest and metadata, not message contents.

ADMIN payloads begin with an op byte and are SIGNED_FULL. Built ops:

op=1 TELEM_QUERY: u16 target_rtr; u32 request_id; IK_pub[32]
op=2 TELEM_REPLY: u16 responder_rtr; u32 request_id; RK_pub[32]; TELEM[48]
op=3 SET: u16 target_rtr; u32 request_id; IK_pub[32]; key_len1; key; value_len1; value
op=4 RESULT: u16 responder_rtr; u32 request_id; RK_pub[32]; status1; msg_len1; msg
op=5 REBOOT: u16 target_rtr; u32 request_id; IK_pub[32]

The public key must hash to packet SRC, and the signature covers the normal canonical image including the op payload. TELEM_QUERY also sets ACK_REQ and uses the normal explicit ACK retry loop; TELEM_REPLY is the eventual data response. Mutating SET and REBOOT requests also set ACK_REQ; receivers ACK only after the signature and target router match, then return a signed RESULT. The first mutating key is telem.interval_ms; built non-RF follow-on keys are rtrsync.interval_ms, rtrsync.delta_ttl_ms, history.time_skew_guard_s, directory.limit, mailbox.ttl_ms, mailbox.limit_per_uid, relay.store_forward_limit, relay.store_forward_ttl_ms, admin.quorum, admin.quorum_window_ms, checkpoint.message_interval, checkpoint.time_interval_ms, checkpoint.verify_window, and checkpoint.audit_limit. lane.plan uses the same SET string layout for targets with a configured lane-plan store: anchor, clear, or comma-separated lane_id:preset:freq_slot:period_s:offset_s:window_s rows, with the anchor row first. It updates stored schedule configuration only; actual RF retune policy remains outside the ADMIN packet layout. tx.enabled off (or 0) is a one-way RF safety key: targets with a platform callback may force their local transmit master switch off and clear queued RF frames, while tx.enabled on is unsupported and remains local-console-only. Identity denylist keys use the same SET string layout: identity.revoke and identity.unrevoke take a 16-hex UID value, update the target router’s per-router idrev denylist, and refuse local identity UIDs. Accepted revoke or clear state changes can fan out through the RTRSYNC trusted-peer revoke state tail below; device-subkey eviction is handled by replacement identity records, not the ADMIN packet layout. The OTA rollout family (OTA.md §5) also rides the same SET string layout: ota.release_pubkey/ota.nightly_pubkey take a 64-hex Ed25519 public key, ota.wave takes a comma-separated 16-hex UID list or clear, ota.health_window_ms/ota.health_max_boots are bounded numerics, and ota.promote confirm is a per-release action key that requires a configured node-local rollout listener (denied as unsupported otherwise, like a missing tx.enabled platform callback). REBOOT has no key/value body; after allowlist and optional quorum, the target calls its node-local reboot callback. Missing or failed callbacks deny the request as unsupported or failed; accepted callbacks return reboot queued. Duplicate retries are re-ACKed but must not call the callback a second time. Operators are deny-by-default through a local allowlist. RESULT.status=0 means applied or accepted by the local callback, 1 means denied, and 2 means valid but waiting for a local admin quorum. Denial covers authorization, unsupported keys or callbacks, invalid values, and local durable-store failure for implementations that wire Admin SETs to persistent config. When a router sets admin.quorum above one, identical mutating requests (SET key/value or REBOOT) must arrive from that many distinct allowlisted identity UIDs within the local quorum window before the mutation applies. admin.quorum itself is nonzero, cannot exceed the target router’s distinct allowlisted operator count, and is authorized by the quorum that was active before the change; admin.quorum_window_ms is nonzero and bounded to the normal 24 h Admin interval ceiling. The built safe keys (telem.interval_ms, RTRSYNC cadence/delta-retention knobs, history display-skew guard, directory/mailbox bounds, relay store-and-forward bounds, Admin quorum/window policy, checkpoint cadence, verification-window, audit-retention controls, lane-plan storage, TX-off safety, and per-router identity revoke controls) may be persisted by node-local state such as lrcd --state-dir or the ESP32 lrckv partition. Reboot is callback-gated and not persisted. The ota.* keys accumulate on the running node (Node::ota_admin_config()) and notify the embedder’s rollout listener; durable OTA config is the embedder’s own store (OTA.md §5), not admin.cfg. Broader RF/TX, remote runtime-retune enablement, and mutating ops remain future operator policy.

BEACON payload v1.1:

u16 rtr; UID8 router_fp; u8 beacon_flags; u8 n_lanes;
LaneDef lanes[n_lanes]; u32 fed_id; u32 time; u32 boot_id; u8 region_id;

router_fp is the first 8 bytes of the router-key fingerprint. boot_id is the router incarnation: it changes on every boot and lets clients distinguish a rebooted router from a massive SEQ3 jump. Because BEACON is unsigned in the current access-network build, clients treat this as advisory until the same boot_id is confirmed by signed WELCOME. When a signed WELCOME confirms a changed nonzero boot_id for a router, the client flushes that router’s channel mirror caches and pending CHANSYNC gap state before accepting new sequenced traffic from the incarnation.

region_id names the row of the reviewed regulatory table (RegionId, core/include/lrc/region_table_gen.h, generated from tools/regions_table.py) the transmitting router is legally configured for. This is a trailing addition to the v1.1 payload (appended after boot_id) — an additive wire event, not a version bump: BeaconInfo::parse_payload’s existing strict length check simply grew by one byte, so a beacon built against the pre-region-id layout is rejected outright by a current parser (safe failure, no deployed compatibility burden to preserve yet) rather than silently truncated or zero-extended.

Numbering rule (append-only, stable): region_id is the RegionId enum’s ordinal, and that enum’s declaration order in region_table_gen.h is the wire numbering — US915=0, AU915=1, EU868=2, EU433=3, AS923_1=4, AS923_2=5, AS923_3=6, JP923=7, KR920=8, IN865=9, RU864=10, RU916=11, NZ865=12, MY919=13, CN470=14. kUnset = 0xFF (kBeaconRegionIdUnset, lanes.h) is the “not configured” sentinel, never a real row. Adding a new region ALWAYS appends to the end of the enum — an existing id’s numeric value must never change once any router in the field could have advertised it (the table’s own doc comment already carries this rule: “To change a region’s numbers: edit the table, re-run the generator, and commit both files together” — for an id that has shipped, “changing a region’s numbers” for the ALREADY-ASSIGNED rows is exactly what append-only forbids; only a genuinely new row may pick the next number).

Mixed-region hearing fails safe. A client that hears beacons naming different region_id values — plausible near a regulatory border, or from a misconfigured/rogue router — MUST NOT merge, average, or otherwise let the mismatched beacon influence its lane plan or timing state. It accepts a beacon’s plan only when the beacon’s region_id matches the client’s own configured region (beacon_region_accepted(), lanes.h); a mismatched beacon is ignored and counted (region.mixed_beacon_ignored, docs/TELEMETRY.md), never treated as a source of legal-parameter truth. A client with no region configured yet accepts every beacon’s lane plan (it makes no TX legal claim until a region is set — the actual TX gate is region_gate_check()/the firmware “region before tx on” invariant, independent of this beacon-acceptance filter); a configured client hearing an unset-region beacon (region_id == kBeaconRegionIdUnset) does NOT accept it — an unset id is a concrete “this router doesn’t know its region,” which is never the same regime as a configured one, so the asymmetry (self-unset accepts all; self-configured rejects unset) is intentional, not an oversight.

LANEPROBE payload:

u16 rtr; u8 lane_id; u8 preset; u8 freq_slot; u8 flags; u16 remaining_ms;

LANEPROBE is emitted by a router at the head of a scheduled non-anchor listen window when it has no downlink batch for that lane. It is a short “floor is open” solicitation: clients with uplink traffic may transmit inside remaining_ms using the advertised preset and freq_slot. flags is reserved in v1.1 and MUST be zero on transmit; receivers MUST ignore unknown bits. The packet is advisory and unacknowledged; normal CAD/backoff and registration/link policy still decide whether a client actually transmits.

RACK (type 0x2B, group C — addressing is SRC only, the issuing router’s UID) is the credit-routing suppression-collapse signal (docs/research/routing-redesign/CONSOLIDATED_ROUTING_PLAN.md Rule C): a router that has ingested a frame (reached rdist==0 for it) emits a RACK so relays still holding that same frame pending in their own contention window can collapse their wait instead of burning the full window before firing. RACK sets FLAGS.PRIO, is semantically TTL=1 (a receiving node never relays a RACK onward — enforced by the type not participating in either relay branch of the receive path, not by a wire TTL field; RACK carries no ERB and therefore no rprog/TTL/hops-taken of its own), and MUST transit the normal (SRC,MSGID) dedup gate like every other frame before any handler sees it — a RACK handled on a fast path that bypasses dedup repeats the exact bug PROTOCOL.md’s LANEGRANT text warns against (Finding 1).

Payload (fixed 12 bytes):

u64 target_dedup_key; // Packet::dedup_key() of the frame this RACK acks
u32 freshness; // issuing router's boot_id, or a monotone per-session counter

target_dedup_key names the exact logical frame being acknowledged so a captured RACK cannot be replayed against a different frame from the same sender; freshness binds the RACK to one router incarnation/session so a captured RACK cannot be replayed to re-suppress a later flow after that router reboots or rekeys. Both fields are covered by the frame’s own Tag8 when FLAGS.TAGGED is set.

Authentication is tiered, matching who can verify it (Rule C / Rule A.2):

  • Registered relay (holds a session key with the issuing router, i.e. p.src): verifies Tag8 under that SK, the same src-checked pattern LANEGRANT uses (Findings 1/2). On success the relay may cancel its own pending forward of the acknowledged frame outright — the instant-collapse win.
  • Keyless/foreign relay (no SK for that router, or verification fails): treats the RACK as an unauthenticated advisory signal that may only shorten its own remaining suppression window, never cancel it — an honest relay that hears no authenticated forward-progress within its bounded timeout T always fires regardless (threat requirement R1; routing_policy.h’s RoutingSuppressionAction::Shorten vs Cancel).
  • Untagged RACK (FLAGS.TAGGED clear): unauthenticated for every receiver, not a parse failure — treated the same as a failed-verification keyless RACK (shorten-only).

The node-side authentication and classification (Node::handle_rack()) is the current landed slice: it deduplicates, verifies Tag8 against every session key this node holds, and classifies the result as RoutingProgressSignal::AuthenticatedRack or UntrustedRack (routing_policy.h). The pending-forward suppression scheduler that consumes this classification to actually cancel/shorten/fire a queued retransmit is TX-timing behavior and is not yet wired into Node — it needs a timer/scheduling seam that is firmware’s job and, because it changes when a node actually keys up, needs human RF sign-off per AGENTS.md rule 5 before it drives real transmit timing on hardware.

LANEGRANT payload (group B, fixed 7 bytes):

u16 rtr; u8 lane_id; u8 preset; u8 freq_slot; u16 ttl_s;

The anchor lane (P0) is the network’s dial tone and its scaling chokepoint, so a router actively offloads registered clients onto faster lanes once the anchor is busy enough to matter (RADIO.md §Lane promotion). LANEGRANT is the router→client carrier for that decision: addressed DST = client UID, Tag8’d with the session key so the client knows it came from its own router, it names the lane (lane_id/preset/freq_slot) the router measured the client able to sustain and a ttl_s after which the grant lapses and the client falls back to the anchor. The router-side decision is the host-tested LaneSchedule::promote_client; the client retunes its uplink to the granted lane until the TTL expires or it is re-granted. The grant is advisory uplink policy only — it never authorizes the client to change TX power or region, and CAD/backoff still gates the actual transmit.

WELCOME payload v1.1 starts with:

RK_pub(32); nonce_c_echo(8); nonce_r(8); u16 rtr; u32 boot_id;

Future channel digests or lane references are appended after boot_id and covered by the SIGNED_FULL trailer.

BYE payload:

u8 reason[PLEN]; // UTF-8 IRC QUIT reason, empty allowed

The sender is the identity going offline. Receivers remove the UID from their directory and render IRC QUIT to local members of channels where that UID was previously seen. Routers may also originate BYE for a client after IRC PING timeout; this keeps radio presence and IRC liveness identical.

Authentication. When the receiving router holds a session key for the BYE’s SRC (i.e. that UID is one of its own registered clients), the BYE MUST carry a valid Tag8 and the router verifies it before tombstoning that attachment; a forged or untagged eviction for a locally registered UID is dropped. A BYE for a UID the receiver did not register carries no shared key to check — it is a federation announcement from the UID’s owning router and is trusted at the same cross-router boundary as the rest of RTRSYNC (a spoof self-heals on the next attested directory refresh, §RTRSYNC).

RTRSYNC payload:

u8 n_streams;
repeat n_streams:
CHAN4 chan;
u16 rtr;
u24 max_seq;
u8 n_directory; // optional; absent in the legacy vector-only form
repeat n_directory:
UID8 uid;
u16 current_rtr;
u32 live_rev;
u8 nick_len; u8 nick[nick_len];
u8 n_channels;
repeat n_channels:
u8 chan_len; u8 channel_display[chan_len];
u8 n_records; // optional; absent when no later sections exist
repeat n_records:
u8 record_len; // bytes from CHAN4 through optional key
CHAN4 chan;
u16 record_writer_rtr;
u32 record_rev;
UID8 founder;
u8 modes_len; u8 modes[modes_len];
u8 topic_len; u8 topic[topic_len];
u8 n_ops; repeat n_ops: UID8 op_uid;
u8 n_bans; repeat n_bans: UID8 ban_uid;
if modes contains "k": u8 key_len; u8 key[key_len]; // JOIN key, 1..64 bytes
u8 n_tombstones; // optional; absent when no later sections exist
repeat n_tombstones:
UID8 uid;
u16 last_rtr; // 0 if unknown
u32 live_rev;
u8 reason_len; u8 reason[reason_len];
u8 n_liveness; // optional; absent when no later sections exist
repeat n_liveness:
u16 rtr;
u32 live_rev;
u8 state; // 1 = reachable, 0 = split/unreachable
u8 reason_len; u8 reason[reason_len];
u8 n_identities; // optional; absent when no later sections exist
repeat n_identities:
u8 record_len;
IdentityRecord record; // IDENTITY.md §6 bytes
u8 n_revokes; // optional; absent when no later sections exist
repeat n_revokes:
UID8 uid;
u32 generation;
u16 writer_rtr;
u8 state; // 1 = revoked, 0 = clear tombstone

Each entry says “I have this stream through max_seq.” A receiver that has a local member for CHAN4 compares the entry against its mirror for (CHAN4,RTR2) and requests the missing range with CHANSYNC; missing messages are still replayed as normal CHANMSG backfill frames. This lets a healed partition converge without waiting for one more channel message to expose the gap. The optional directory section is a bounded online registration/member snapshot with one record per (UID,current_rtr) attachment: liveness generation, current nick, and channel display names where that attachment is presently seen. Routers use it to route DMs without requiring a shared-channel warm-up, fan out online DMs during make-before-break roaming, and rebuild presence after missed JOIN/PART edges. The optional channel-record section snapshots founder, ops, bans, simple modes, topic, and the keyed-JOIN secret for one channel hash. Receivers apply a snapshot only when (record_rev, record_writer_rtr) is newer than their local copy, then persist it under their own router scope so first local JOIN enforcement can load it before admitting a user. The optional tombstone section repairs missed BYE edges after a partition: receivers remove only the matching (UID,last_rtr) attachment from their directory, render QUIT where no other attachment for that UID remains in the channel, and temporarily suppress directory snapshots for that attachment at the same or lower live_rev. A later login for the same UID on the same router advertises a higher live_rev, which wins over the older tombstone; another router’s live attachment is unaffected. The optional liveness section gossips router reachability transitions without deleting directory entries: receivers apply only newer live_rev values for a router, render IRC QUIT/JOIN for affected attachments on state changes, and skip unreachable router attachments when choosing a DM egress. The optional identity section gossips signed identity records for locally registered users and already verified cached records; receivers run the same IdentityRecord signature checks as KEYRESP and accept only verified records into the bounded identity cache. The optional revoke section is a trusted-peer-only generationed UID state fanout. Senders include their latest non-local revoke-state rows, including clear tombstones created by key unrevoke or identity.unrevoke. Receivers ignore radio-ingress rows and local identity UIDs, validate the entire revoke tail before applying any row, and apply only newer (generation, writer_rtr) tuples for each UID. Accepted state=1 rows clear any cached identity record before persisting the revoke; accepted state=0 rows persist as clear tombstones so older revoke rows cannot resurrect a UID after the clear converges. RTRSYNC is a control-plane summary only; message text never appears in it. When revoke-state rows are the only extension after identities, senders include the explicit zero n_identities byte before n_revokes so older parsers can no-op the unknown tail.

Green-field migration note: this revoke-state row replaces the earlier grow-only 8-byte UID revoke row before deployed compatibility exists. Nodes with the older parser reject the new tail without partially mutating revoke state; no LPP header version bump is required for this bench-only transition.

Routers may compact an RTRSYNC by sending only changed optional directory, channel-record, tombstone, liveness, identity, or revoke rows while keeping the same ordered section counts shown above. There is no delta flag on the wire: a missing optional row means “no update in this packet”, not deletion. Senders retain dirty rows for a bounded rtrsync_delta_ttl_ms window so bounded-fanout anti-entropy can reach peers skipped in earlier rounds, and periodically send a full optional snapshot as the repair path for missed deltas. Receivers do not distinguish compact and full payloads; they apply every included row through the normal newer-generation, signature, or trusted-peer revoke-state checks.

MAILBOXSYNC payload v1 carries one mailbox reconciliation body for one recipient UID to one target router:

u16 target_rtr;
bytes body;

The body is one of:

LCMI inventory:
bytes magic = "LCMI";
u8 fmt_version = 1;
u16 source_rtr;
uid8 dst;
u16 n_entries;
repeat n_entries:
uid8 src;
u32 msgid;
u64 expires_ms;
u8 kind; // 0 live, 1 tombstone
u64 row_digest; // first 64 bits of STORAGE.md canonical row SHA-256
LCMR repair request:
bytes magic = "LCMR";
u8 fmt_version = 1;
u16 source_rtr;
uid8 dst;
u16 n_keys;
repeat n_keys:
uid8 src;
u32 msgid;
LCMB rows:
bytes mailbox_snapshot; // STORAGE.md LCMB snapshot or row subset

Only the tombstone-capable LCMB snapshot version (fmt_version = 2, the version encode_snapshot emits) is accepted. The pre-tombstone v1 layout — flat live rows with no deletion framing — predates standby reconciliation, carried no production data in this green-field system, and has been removed: decode_snapshot rejects any LCMB snapshot whose version byte is not 2. Encoder output is unchanged by this narrowing, so the on-flash image for the current format is byte-identical.

MAILBOXSYNC is router-to-router transport for standby mailbox convergence, not federation gossip. A dirty sender first emits LCMI only through a target-aware authenticated peer egress after RTRSYNC has shown a reachable same-UID attachment on another router. It is never broadcast to every peer, never sent on RF, and receivers drop radio-ingress MAILBOXSYNC frames. Peer receivers still ignore frames whose target_rtr is not their router id and process only bodies for a locally known UID or an already materialized mailbox. LCMI carries no message text: receivers compare row keys, expiry, tombstone state, and digest, then answer with LCMR for rows they need. A request is answered with an LCMB row subset and imported by the tombstone-wins set-union described in STORAGE.md, so delivered or expired tombstones suppress stale live rows from old standbys. If both peers advertise different same-horizon live content for the same (SRC,MSGID), each side may exchange the exact row subset once and the normal LCMB tie-breaker decides the stable row. Any MAILBOXSYNC body larger than one LPP frame uses the standard FLAGS.FRAG header and is reassembled only after peer ingress; a body that exceeds the 16-fragment reassembly cap is dropped locally. Message text still never appears in RTRSYNC or LCMI; it appears in MAILBOXSYNC only when an authenticated target peer requests an exact LCMB row subset.

TELEM payload v1.1 is a fixed 48-byte rollup:

u8 fmt_version; // 1
u8 flags; // bit0 battery, bit1 duty, bit2 queues, bit3 store
u16 rtr;
u32 unix_time; // 0 if undisciplined
u32 uptime_s; // monotonic, wraps
u16 battery_mv; // 0 when absent
u8 battery_pct; // 0..100, 255 when absent
u8 duty_cycle_pct; // 0..100, 255 when absent
u16 tx_queue_depth;
u16 peer_queue_depth;
u32 store_horizon_s; // 0 when unknown
u32 rx_ok;
u32 rx_dedup;
u32 tx_ok;
u32 tx_fail;
u32 seq_gap;
u32 fed_links;

The counters are snapshots from the common telemetry registry. The payload contains no message text, no third-party UID, and no location. TELEM is never ACKed. Receivers keep the latest valid rollup per router for local debugging; longer history is a storage policy, not a wire-format feature.

MeshCore-style source routing, 1 byte per hop:

u8 path_len; u8 path[path_len]; // path[i] = first byte of relay i's UID

Pinned-path relays compare path[hops_taken] against their own UID byte; on match they relay and increment hops. Discovery packets use the same block with path_len == hops_taken: a relay appends its UID byte, increments path_len and hops_taken, and forwards. A relay drops the frame when hops_taken exceeds the block or a discovery block is already full. One byte per hop is ambiguous on purpose — it is a hint, not security; ambiguity occasionally causes a duplicate relay, which dedup absorbs. Discovery/pinning behavior is described in ROUTING.md.

Relays may opportunistically store an already-mutated relay frame when no egress is currently available, then forward that exact frame before a local TTL expires. This is a bounded local queue only: it does not add fields, rewrite payloads, mint ACKs, or change the source/destination/router semantics.

For online DM path discovery, an unpinned sender transmits the DM with FLAGS.PATH and path_len=0. Relays append their UID bytes; the recipient’s ACK carries the path bytes reversed so the ACK can return on the same discovered route. The sender reverses those ACK path bytes before pinning the next outbound DM to that UID. If the ACK retry budget expires for a pinned DM, the sender abandons the pin; the next DM to that UID starts discovery again.

Registration uses the same mutable block. A HELLO always carries FLAGS.PATH: empty when discovering a router path, or populated with the client’s pinned router path. A WELCOME that answers a discovered HELLO carries the relay bytes reversed so it can return on the discovered path. The client pins the reversed-back path only after validating the signed WELCOME; if the HELLO retry budget expires while using a pinned router path, the client clears that pin and the next HELLO starts discovery again.

Edge Routing Block (present when FLAGS.ERB, v2 only)

Section titled “Edge Routing Block (present when FLAGS.ERB, v2 only)”

Layout status: FROZEN as of the Wave 2 LPP v2 batch (routing-redesign Phase 2.1, docs/research/routing-redesign/NEXT_PASSES.md). The field offsets/widths below are pinned byte-for-byte by tests/test_packet.cpp::packet_erb_v2_golden_bytes; changing them is a new wire-version event, not a patch. rack_rtr1 is a hint field only (see §RACK) — it names the router UID a RACK a relay might see would target, it does not itself carry a RACK.

The v2 Edge Routing Block (ERB) is the wire carriage for the routing-redesign return-path machinery (docs/research/routing-redesign/). It coexists with the v1 FLAGS.PATH source-routing block — a v2 frame carries an ERB, a v1 frame carries a Path block, never both. v1 nodes blind-relay v2 frames unchanged (§Versioning); v2 nodes parse the ERB to drive the contention window, return-descriptor state machine, and the asymmetric multi-SF return lane (return_lane.h).

u4 rprog; // router-distance progress advertised by the sender
u4 reserved; // 0 on transmit; receivers MUST ignore
u8 hint; // return-path hint bits (TRAIL ordering seed)
u8 rack_rtr1; // first byte of the router UID a RACK targets
u8 trail_len; // 0..kMaxPathHops; number of TRAIL relay-UID bytes
u8 trail[trail_len]; // first byte of each relay UID, observed-delivery order
// Return-lane descriptor (the asymmetric multi-SF decision, return_lane.h):
u8 rd_mode; // 0=BROADCAST,1=RELAY_VIA,2=LANE_SCHED,3=UNKNOWN
u8 rd_lane_id; // lane the return leg should ride (preset index)
u8 rd_preset; // SF preset for the return leg (presets.h ladder)
u4 rd_ttl_quart; // remaining TTL for the descriptor, in quarter-units
u4 rd_reserved; // 0 on transmit

The ERB is mutable (relays append TRAIL bytes, the router rewrites rd_* as the return lane is learned) and is therefore in kMutableMask and excluded from the canonical image — exactly like the v1 Path block. No ERB field feeds a trust decision (CONSOLIDATED §6.1): an attacker may mutate it freely, and the worst outcome is a suboptimal or failed return path, never a forged origin or a bypassed access control. The observed- delivery gate (return_path.h) confirms a return lane actually works before the router commits to it; poisoned TRAIL/RD values decay out (rd_decay, self-heal).

A v2 frame is signaled by VT bits 7..6 = 0b01. The flag bit that v1 names EXT_ADDR (bit 7 of FLAGS) is repurposed in v2 as FLAGS.ERB: its presence selects the ERB block layout where v1 selected extended addressing. Because the version bits gate the interpretation, a v1 node never sees FLAGS.ERB — it sees an unknown version and blind-relays.

v2 replaces common-header byte 2 — v1’s TH (TTL remaining + hops taken) — with AC, the frame’s remaining airtime-credit budget, a raw u8 unit count. This is the wire half of credit routing (docs/research/routing-redesign/CREDIT_ROUTING.md, normative host layer in CREDIT.md / core/include/lrc/credit.h); byte 2’s interpretation is selected by the same version bits that select the ERB, so a v1 frame’s byte 2 is completely unchanged.

TH was asked to be three things at once — loop guard, flood-radius bound, and resource bound — and did all three badly (a hop is not a unit of cost: one SF12 relay burns ~2.5 s of airtime, one SF7 relay ~45 ms, a 100× gap the hop counter can’t see). v2 deletes TH rather than relocating it and replaces its three jobs with three independent mechanisms, only the third of which is a wire field:

Job Mechanism Wire?
Loop guard (SRC, MSGID) dedup ring (§Dedup, unchanged) no
Uplink shape (second half of the loop guard) gradient descent: relay only if own router-distance < the frame’s ERB.rprog no (ERB, already on wire)
Flood width contention window + suppression (routing-redesign Rule A) no (RACK carries the collapse signal)
Resource bound AC yes — this section

Unit. AC counts in kCreditUnitMs-sized units — frozen at 50 ms by sweep evidence (research/traces/credit-freeze/unit_sweep.txt): 25 ms puts a 6 s Chat budget at 94% of the u8 ceiling and spans only 6.38 s; 100 ms overprices fast lanes at the min-debit floor (BURST charged +1011% of its real airtime); 50 ms fits every class with ≥2× ceiling headroom and spans 0..12.75 s of cumulative path airtime.

Debit rule. Before retransmitting a v2 frame, a relay computes the time-on-air it will actually incur at the preset it is about to send on (airtime_ms(), presets.h — the same function AirtimeLedger uses for duty-cycle admission, so a credit debit and a legal duty charge can never disagree about what a frame costs) and debits max(1, ceil(toa_ms / kCreditUnitMs)) from AC. If the debit exceeds the remaining credit, the frame is dropped (credit.drop_exhausted) rather than forwarded partially. The minimum debit of 1 bounds even the cheapest preset to at most 255 relays along a single path.

Forwarding gate. A relay only considers forwarding a v2 frame at all if doing so strictly decreases router-distance (ERB.rprog vs. the relay’s own distance estimate) — the gradient-descent predicate is checked before any airtime is priced (credit.drop_progress when it fails), so a frame going nowhere is never charged. This predicate, not a hop count, is what makes a v2 forwarding loop provably impossible: router-distance is a potential function bounded below by zero and strictly decreasing along any sequence of legal relays.

Origin budgets are per traffic class (CreditClass, credit.h), stamped at origination time from credit_initial_budget() — the packet type already says which class a frame belongs to, so no extra wire field names it. Values are frozen by the v2 sweep (evidence: research/traces/credit-freeze/; real-traffic validation: tests/scenarios/credit_depth.scn + credit_depth_trace.jsonl):

Class Constant Initial AC ≈ airtime Traffic
Discovery kCreditBudgetDiscovery 100 5 s HELLO, empty-path probes
Chat kCreditBudgetChat 120 6 s CHANMSG/DM
Control kCreditBudgetControl 60 3 s ACK/RACK/LANEGRANT
Bulk kCreditBudgetBulk 0 (n/a) n/a DCCDATA rides the burst lease’s own airtime budget, not AC

Router re-origination. A router forwarding traffic across a scope boundary (edge→backbone or backbone→edge) is the path authority and stamps a fresh AC from the class table rather than carrying the inbound remainder forward — each cell’s spectrum is a separate resource domain, so end-to-end reach across a federation is routers × per-cell budget, not one decrementing counter that starves on a long federation chain. The fresh stamp is coupled to the target cell’s live regulatory state (credit_reorigination_budget(), WS-REGION’s AirtimeLedger/RegionPlan interfaces): it scales down (never up) with live duty headroom on duty-governed sub-bands or measured LBT deferral pressure on LBT-governed regions, floored so a starved cell still re-originates a minimum viable budget instead of silently black-holing every frame that crosses into it, and capped at the class ceiling so re-origination can never mint more reach than the class table allows. Node::credit_reorigination_stamp() is the thin host-side wire-up, sourced from the same RegionTxContext/ AirtimeLedger state set_region_tx_context() already owns.

Threat notes. AC, like TH before it, lives in the mutable region — byte 2 is unconditionally zeroed in the canonical image (canonical_image() “TTL/hops change in flight” comment applies verbatim to AC) — because relays must rewrite it in flight and keyless foreign relays hold no MAC key to protect it with. Inflating AC upward burns spectrum only in the attacker’s own radio neighborhood (every relay’s local AirtimeLedger duty cap is the hard ceiling regardless of what a frame claims); deflating/ stripping it is indistinguishable from a relay simply not forwarding, the already-accepted grayhole case bounded by suppression’s R1 guarantee. No trust decision ever reads AC — like the ERB, it can steer efficiency, never identity, access, or delivery attestation.

Observability. Every credit decision is counted: credit.relayed/credit.drop_progress/credit.drop_exhausted at the per-hop forward/drop decision, credit.reoriginate/credit.reorig_floor/ credit.reorig_lbt_limited at router re-origination (docs/TELEMETRY.md). The live SSE/observatory feed and lrcsim’s trace recorder share one observation seam (Node::Config::on_event’s NodeEvent.credit/ debit_units fields, docs/OBSERVATORY.md) rather than each independently re-decoding the AC byte off the wire.

Frozen by sweep evidence (research/traces/credit-freeze/ — deterministic, regenerable tables over the real airtime_ms()/credit.h/ region primitives, machine-checked twins in tests/test_credit.cpp, tests/test_sim_credit_sweep.cpp, and the CI-gated tests/scenarios/credit_depth.scn):

Constant Frozen value Deciding evidence
kCreditUnitMs 50 ms unit_sweep.txt — u8-ceiling headroom vs fast-lane quantization knee
kCreditBudgetDiscovery/Chat/Control/Bulk 100 / 120 / 60 / 0 credit_depth_trace.jsonl — within a cell the 4-bit gradient binds fast lanes (15 relays) and the debit binds slow ones (ANCHOR: 1 relay) at every candidate value, so the shipped ratios stand
kCreditMinDebitUnits 1 min_debit.txt — floor 2 halves legitimate fast-lane reach; the 255-relay pathology is already double-gated (dedup + the 4-bit ERB.rprog bounding any gradient-legal path to 15 relays per re-origination domain)
kCreditReoriginationFloorUnits 8 min_debit.txt/README.md — one REACH-class hop or several fast-lane hops; deliberately NOT an ANCHOR hop (~71 units): a congested cell trickles traffic on cheap lanes
kCreditLbtPriorDeferrals 16 lbt_prior.txt — evidence-proportional trust curve; 4 too credulous, 64 pins recovered channels lean
Duty normalization fraction-of-own-cap duty_normalization.txt — the absolute shape was a dead knob on every sub-band capped below ~7% duty (EU868 g/g1/g4, RU864/EU-g2, JP923 starved permanently, idle or not); this freeze changed credit_reorigination_budget()’s duty scaling accordingly

Two findings the freeze recorded beyond the constants themselves: the schematic hop table in CREDIT_ROUTING.md §3 overstated ANCHOR reach (2 hops claimed; real airtime gives debit 71 at 55 B → 1 relay), and on fast lanes the binding per-cell constraint is the 4-bit ERB.rprog gradient (15 relays per re-origination domain), not the credit budget — AC’s job there is bounding cumulative airtime (12.75 s), not hop count. Known evidence limits (Mode A cannot drive live re-origination or LBT choke end-to-end) are recorded in the evidence README; the bench revisits if reality disagrees.

Fragmentation header (present when FLAGS.FRAG)

Section titled “Fragmentation header (present when FLAGS.FRAG)”
u8: bits 7..4 = frag index, bits 3..0 = frag count-1 (max 16 fragments)
u8: frag group (origin-local counter, distinguishes concurrent fragmented msgs)

Fragments share the parent’s MSGID group via frag group; reassembly buffers live in PSRAM and expire after 30 s. MAILBOXSYNC reassembles fragments before the normal (SRC, MSGID) dedup check so sibling fragments are not mistaken for duplicates. Anything larger than 16×~200 B belongs in a DCC stream or a future streaming row-transfer protocol, not in fragments.

8 bytes appended after the payload. Computed as SipHash-2-4(key, VT‖FLAGS*‖addressing‖payload) where FLAGS* is FLAGS with the mutable bits (PATH, VIA_TCP, and in v2 ERB) masked out, byte 2 (TH in v1, AC in v2) excluded entirely (both change in flight), and the mutable path/ERB block skipped. Which key — session key or chat key — depends on packet type; see IDENTITY.md §4. Current nodes emit chat-key tags on CHANMSG and DM frames. A receiver that has a verified identity record for SRC rejects an untagged or bad-tag chat frame and bumps rx.badtag; unknown senders are still accepted so discovery can bootstrap.

Full signature trailer (present when FLAGS.SIGNED_FULL)

Section titled “Full signature trailer (present when FLAGS.SIGNED_FULL)”

u8 keyhinted25519_sig[64] over the same bytes Tag8 would cover. keyhint selects which of the signer’s registered keys (identity=0, device-sub-key=n) signed. Root-only operations verify with keyhint=0 and the identity public key. For cached signed-DM policy, keyhint>0 verifies against the matching validated subkey in the sender’s cached IdentityRecord; unknown or invalid hints fail signature verification.

Every node keeps a ring of the last 512 dedup keys (SipHash(fixed_key, SRC ‖ MSGID), 8 bytes each = 4 KB). A packet whose key is in the ring is dropped silently. Ring size is configurable; routers with PSRAM default to 8192. For cached chat senders, Tag8 verification happens before dedup insertion so a bad frame cannot reserve the key and block a later valid copy; otherwise dedup happens before TTL/path processing.

The transport assumption is “every packet may be lost; the stream must heal”.

  1. The sequencing router stamps each channel message with SEQ3.
  2. Clients remember last_seq[chan,rtr]; nodes with cold state persist that cursor per mirrored stream. On receiving seq n > last+1, or on seeing an RTRSYNC max vector above a restored cursor, the client marks a gap. Gaps are not chased immediately (the missing packet may still be in flight via another path); after gap_grace (default 5 s) the client sends CHANSYNC REQ listing up to 8 ranges: u8 op=REQ; u8 nranges; {u24 from; u24 to}[nranges].
  3. The router, or any relay mirror that has every requested message cached, replays the stored messages in SEQ3 order with VIA_TCP-style suppression: replayed packets carry the original SRC/MSGID/SEQ so dedup still works for third parties that already have them. The hot ChanStore ring is checked first; if it no longer holds the full range, an already prepared warm-history index may serve the complete range from lrclog record references. A request must not trigger a cold flash scan to build that index. If the serving node has the sender CTK, it reconstitutes the TAGGED trailer because Tag8 covers SEQ3 as the unstamped sentinel. A relay with only a partial cache may answer what it has, but must still forward the request upstream so it cannot starve the missing range. Ranges are inclusive serial intervals in the 24-bit space and may wrap across 0xFFFFFE → 0; 0xFFFFFF remains the unstamped sentinel and is never replayed as a real sequence. Dedup suppresses flood loops, not local mirror repair: if a node first saw a CHANMSG only as transit, a later replay is still offered to any current local mirror, and the ChanStore sequence check prevents duplicate IRC delivery.
  4. On JOIN, the router’s join response includes latest_seq, and the client may request the last N messages (default 10, max 100 on LoRa, unlimited on TCP) the same way. Nodes with cold client state also persist the local identity’s joined-channel list; after IRC re-registration they restore those channels before mailbox delivery, then use the persisted last_seq[chan,rtr] cursor to chase any missed remote stream ranges with ordinary CHANSYNC. This is how “scrollback on join” and “client reboot backfill” work with zero new mechanism — backfill is just a gap from an existing cursor.

Because each (channel,router) stream is linear, federation reconciliation is a max-seq vector, not a set-reconciliation problem — routers exchange {CHAN4, RTR2, max_seq} triples in RTRSYNC and fetch what they miss with ranged CHANSYNC. This is the main reason sequencing lives in routers. (Minisketch-style IBLT reconciliation is kept in reserve for the DM mailbox case, where there is no single sequencer; see ROUTING.md §Mailboxes.)

  • CHANMSG upstream (client→router) sets ACK_REQ; the router’s stamp (the sequenced copy coming back on the downlink, which the client hears) doubles as the implicit ACK. If the client doesn’t hear its own message sequenced within ack_timeout (preset-scaled, default 8 s at fast lanes, 30 s at SF12), it retries with the same MSGID (dedup makes retries safe), up to 3 times, then reports failure to the IRC layer (§Delivery status).
  • DM uses explicit ACK with exponential backoff (1.5×, jittered ±25%). ACK payload is: acked_msgid (u32 LE) + status (u8; 0 = accepted, nonzero = terminal failure for that sender queue entry). The access-network spine implements this loop for online DM: the sender stores the original encoded frame and retries the same (SRC, MSGID) until the ACK arrives or the retry budget expires. A recipient router may ACK after delivering to an online local session or after accepting the DM into that identity’s local mailbox. If the first ACK is lost, the receiver re-ACKs duplicate DM retries without delivering or mailboxing the IRC message twice. Path-pinned DMs normally reuse the same pinned encoded frame during that retry budget. If the pinned router becomes unreachable while the directory already has a different reachable attachment for the same destination UID, the sender clears the stale pin, retargets the pending ACK state to that attachment, rewrites only the mutable PATH block to discovery form (path_len=0, hops_taken=0), and retries the same (SRC, MSGID). Payload, tags, and signatures remain valid because the path block is excluded from the canonical image. The standby router’s reverse-path ACK then pins the fresh route for later DMs. If no alternate attachment is reachable, the sender keeps the pinned retry loop until the budget expires, then clears the pin for future DMs.
  • HELLO uses the same retry discipline, but its signed WELCOME is the terminal acceptance signal instead of a separate ACK: the client stores the original signed HELLO and retries the same (SRC, MSGID, nonce_c) until WELCOME arrives or the retry budget expires.
  • ADMIN telemetry queries and mutating SET/REBOOT requests use explicit ACK with the same packet shape and retry discipline; signed TELEM_REPLY or RESULT packets are the eventual data responses. For mutating requests, ACK only means the signed request was accepted for processing by the target router; a quorum-gated command is not applied until a signed RESULT reports status=0.
  • DCCCTL uses explicit ACK with the same saved-frame retry discipline as DM. The built core slice routes raw DCCCTL op payloads to a learned online recipient, retries the same (SRC, MSGID) after loss, and re-ACKs duplicate retries without running the future DCC state machine twice. The IRC gateway now converts remote CTCP DCC SEND offers into DCCCTL OFFER.
  • KEYREQ/KEYRESP is request/response without a separate ACK: the response is the data. Receivers verify the embedded IdentityRecord signature before caching it, and same-epoch cache refreshes must keep the same pubkey and CTK.
  • BEACON, TELEM, LANEPROBE are never acked.

DCCCTL OFFER payload: op=1, name_len1, filename, size64, content_hash64, chunk_size16. The current CTCP interception slice sets content_hash64=0 because it has not staged bytes yet; a local sidecar that has staged bytes may publish the dcc_content_hash64() value in the same field. The sender’s original CTCP ip32:port is local sidecar metadata only; it is never serialized into the LPP DCCCTL OFFER. DCCCTL ACCEPT payload: op=2, offer_msgid32; it confirms the specific OFFER. Both endpoints derive the DCCDATA stream id from the sender UID, receiver UID, and original offer_msgid32, so the ACCEPT does not need to carry another field. The derivation is SipHash-2-4(fixed_dcc_stream_key, sender_uid8 || receiver_uid8 || offer_msgid32_le), truncated to 16 bits with zero remapped to 1. The current gateway surfaces that acceptance to the offerer and notifies local sidecars on both endpoints with the derived stream id plus size, hash, and chunk budget. When a node embedding configures a local DCC listener address, inbound offers also synthesize a receiver-local CTCP DCC SEND using that ip32/port so stock IRC clients never see the sender’s original LAN coordinates. Other DCCCTL ops are LANE_GRANT, RESUME, and CLOSE. LANE_GRANT payloads are fixed-width burst-lane authorizations:

op=3, stream_id16, preset8, freq_slot8, token32, ttl_s8, airtime_budget_ms16

preset is one of P0..P7; DCC burst grants normally use P5 or faster when the RF capability intersection allows it, but may name a slower in-plan fallback. ttl_s is 1..120. token32 scopes the grant renewal/teardown in the future burst-lane scheduler. airtime_budget_ms16 is the sender’s maximum DCCDATA airtime under this grant before it must yield or renew. The core sidecar tracker accepts grants only for its configured local stream, computes expiry from the Clock seam, treats a later valid grant for the same stream as a renewal, and clears the active grant on matching CLOSE.

RESUME payloads are compact retransmit bitmaps:

op=4, stream_id16, first_chunk16, bit_count8, bitmap[ceil(bit_count/8)]

Bits are least-significant-bit first; bit i requests retransmission of first_chunk + i. bit_count is 1..64 in v1.1 so a request stays small enough for control lanes and can be repeated with a later first_chunk for larger gaps. CLOSE payloads are compact teardown controls:

op=5, stream_id16, status8

Status values are 0=complete, 1=cancel, 2=error, and 3=hash_fail. The control is carried over the same reliable DCCCTL ACK/retry path as other DCC controls. It tears down only the named local sidecar session; it does not change packet framing, channel membership, or router lane state. DCCDATA payload is stream_id16, chunk_seq16, bytes...; the current core primitive emits at most 192 data bytes per chunk and delivers accepted local chunks to a caller-provided DccDataSink. The built reassembler validates stream identity, chunk size, and final-chunk length, maintains the receive bitmap, and can generate the RESUME payload above for missing chunks. The built sender-side scheduler stages bytes once at transfer setup, exposes initial chunk views, rejects RESUME requests for the wrong stream, and maps valid requested bits to retransmit chunk sequences while ignoring bits outside the staged chunk count. DccOutboundTransfer is the sidecar pump over that scheduler: it emits initial chunks through a caller-provided sender, accepts inbound RESUME controls only from the expected peer/direction, queues a bounded retransmit list without allocating in the receive callback, and prioritizes those retransmits on the next pump pass. Sender staging, retransmit pumping, and RESUME enumeration are local sidecar behavior over existing DCCDATA and DCCCTL RESUME; they do not alter LPP framing or ACK policy. DccBurstGrantTracker similarly tracks one accepted stream’s active burst grant, rejecting wrong-stream grants and exposing only unexpired grants. DccBurstRfIntent maps a ready matching lease to exact local radio frequency/preset parameters and a chunk budget for a future board driver; all unready lease states preserve control state without authorizing a retune. A receiver-side reassembler treats content_hash64=0 as “unknown”; when a nonzero expected hash is supplied, it verifies the completed staged bytes, raises dcc.hash_fail on mismatch, and withholds the failed buffer from local sidecar delivery. lrcd reports that receiver-side mismatch to the sender with DCCCTL CLOSE HashFail. DCCDATA chunks are individually tagged and cached identity records enforce that tag before local delivery. Content is not encrypted — like everything in chIRpChat it is signed/tagged only. The full lane choreography (temporary fast preset, band-pass intersection) is in RADIO.md §Burst lanes; the IRC-side interception (/dcc CTCP rewriting) is in ARCHITECTURE.md §DCC gateway.

IRC has no delivery receipts; we surface them without breaking clients:

  • Default: silence on success, and on final failure the gateway emits NOTICE from service user *lrc: not delivered: DM <nick>: <prefix>.
  • Power users: /msg *lrc track on upgrades to per-message NOTICE ticks for ACK-backed DMs (queued, retry n, delivered, or not delivered), and /msg *lrc status dumps that user’s pending outbound ACK queue with per-message state (airborne, retry count, and summary).
  • Clients supporting IRCv3 echo-message + labeled-response get proper async confirmation; we advertise the caps and attach labels (CAP negotiation is implemented in the gateway, stock clients that don’t negotiate see plain RFC1459).
Lane preset usable payload target
P0 (SF12/125) anchor ≤ 64 B packets preferred; hard max 255
P1–P3 mid ≤ 160 B
P4+ fast / TCP 255 B

The IRC gateway soft-wraps long PRIVMSG lines at the lane budget minus overhead, exactly like IRC servers wrap at 512; users never see this.

VT version bits 7..6 of byte 0:

  • 0b00 = v1 — the frozen layout pinned by tests/test_packet.cpp::packet_golden_bytes. FLAGS.PATH (bit 3) selects the source-routing Path block; bit 7 is EXT_ADDR, reserved and MUST be 0.
  • 0b01 = v2 — adds the Edge Routing Block (§Edge Routing Block) and replaces byte 2 TH with AC (§The AC byte (v2)). In v2, bit 7 of FLAGS is FLAGS.ERB (its presence selects the ERB layout); the v1 Path block is unchanged and still selected by FLAGS.PATH. A v2 frame carries an ERB xor a Path block, never both. Byte 2 is always AC in a v2 frame regardless of whether it carries an ERB — v2 has no TTL/hops-taken concept at all, on or off the ERB.
  • 0b10, 0b11 = reserved.

Decision: re-reserved, not specified, for this freeze. LPP v1.1 reserved FLAGS bit 7 as EXT_ADDR for a future 16-byte-UID extended addressing block (IDENTITY.md §1’s stated driver: at ~6×10⁹ users the 8-byte UID’s birthday bound becomes a real collision risk). This wave’s v2 batch does not spec that block. Instead it consumes the same bit 7 for FLAGS.ERB — the routing-redesign carriage this wave prioritizes — which means v2 has no free flag bit left for extended addressing at all: bit 7 is now version-gated between two different meanings (EXT_ADDR in v1, ERB in v2) and every other FLAGS bit already has a job.

Rationale for deferring, not speccing now:

  • Scope mismatch. A 16-byte UID is not a wire-layout change alone — it touches UID derivation itself (SHA-256(IK_pub)[0..8] becoming [0..16] or a parallel long-UID path), which cascades into addressing blocks on every packet group, the dedup key, Tag8/signature coverage, and the identity cache — an identity-model change on the scale of a new major version, not a within-freeze wire addition. Landing it “for real” inside this wire-layout freeze would either be rushed or block the freeze indefinitely; the roadmap’s explicit rule is that EXT_ADDR must not become that excuse.
  • No present driver. Nothing has shipped yet (ROADMAP.md’s “nothing has shipped, so v2 is the moment”); the birthday-bound collision risk applies at population scales far beyond this project’s current or near-term reality. Reserving the concept costs nothing; committing to a specific 16-byte layout now would freeze a design decision (block position, which packet types carry it, how it interacts with the ERB that just took its bit) years before the information needed to make it well exists.
  • Honest reservation, not silence. The forbidden outcome is leaving EXT_ADDR vague across the freeze — this section is that explicit statement. What “re-reserved” means concretely: a future extended-address wire event is v3 (VT bits 0b10), not a bit stolen from v1 or v2. v3 gets its own full 6-bit FLAGS byte to design fresh (nothing forces it to reuse bit 7’s role at all) rather than inheriting v1/v2’s bit-7 ambiguity. Until that v3 event, transmitters MUST NOT set EXT_ADDR/ERB outside their respective version’s defined meaning (already enforced by decode()), and no packet type carries a 16-byte-UID addressing block.

A v1 node MUST ignore packets with an unknown version locally, and MUST relay them only if common-header FLAGS.PATH processing succeeds (forward compatibility for relays). The relay does not parse the future payload, addressing, or trailers; it only applies normal forwarding mutations to common fields (TH, and VIA_TCP when the frame traverses a TCP peer link). v2 nodes parse the ERB but otherwise honor the same common-header rules; a v2 relay’s forwarding decision uses the credit/gradient-descent gate (§The AC byte (v2)) in place of TH’s TTL check.

The v1→v2 transition is additive on the wire: the codec’s decode() accepts both versions, the canonical-image / tag / signature rules are unchanged (the ERB, like the Path block, is mutable and image-excluded, and byte 2 was already unconditionally excluded from the canonical image before v2 existed — AC inherits that exclusion for free), and the v1 golden bytes are preserved byte-for-byte. A v2-aware node emits v1 frames by default and v2 frames only when it has an ERB to carry, maximizing the population of nodes that can blind-relay either. Current implementation note: encode()’s v1/v2 selection is driven by Packet::has_erb, so a v2-only frame with no ERB data (e.g. a minimal AC-only frame) must still set has_erb/FLAGS.ERB with an empty (trail_len=0) ERB block today — the smallest legal v2 frame shape, 8 bytes larger than a hypothetical bare-AC v2 frame. Decoupling AC from ERB presence (a type-driven or explicit-flag version selector) is future work, not required by this wave’s freeze: RACK (§RACK), which also wants AC semantics without ERB data, is emitted v1-shaped today for the same reason.