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.
Design constraints
Section titled “Design constraints”- 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.
Byte order and notation
Section titled “Byte order and notation”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).
Common header (8 bytes)
Section titled “Common header (8 bytes)”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).
Packet types
Section titled “Packet types”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.rt1→sha256("pizza")[0..4]).RTR2= the short id of the sequencing router (the router that stampedSEQ3). 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.0xFFFFFFfrom 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 assignableSEQ3for 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_32The 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; valueop=4 RESULT: u16 responder_rtr; u32 request_id; RK_pub[32]; status1; msg_len1; msgop=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.
BEACON region id (WS-REGION)
Section titled “BEACON region id (WS-REGION)”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 (PROGRESS-ACK)
Section titled “RACK (PROGRESS-ACK)”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 acksu32 freshness; // issuing router's boot_id, or a monotone per-session countertarget_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 samesrc-checked patternLANEGRANTuses (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
Talways fires regardless (threat requirement R1;routing_policy.h’sRoutingSuppressionAction::ShortenvsCancel). - Untagged RACK (
FLAGS.TAGGEDclear): 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.
Lane promotion (LANEGRANT)
Section titled “Lane promotion (LANEGRANT)”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 allowedThe 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 formrepeat 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 existrepeat 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 existrepeat 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 existrepeat 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 existrepeat n_identities: u8 record_len; IdentityRecord record; // IDENTITY.md §6 bytes
u8 n_revokes; // optional; absent when no later sections existrepeat n_revokes: UID8 uid; u32 generation; u16 writer_rtr; u8 state; // 1 = revoked, 0 = clear tombstoneEach 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 subsetOnly 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; // 1u8 flags; // bit0 battery, bit1 duty, bit2 queues, bit3 storeu16 rtr;u32 unix_time; // 0 if undisciplinedu32 uptime_s; // monotonic, wrapsu16 battery_mv; // 0 when absentu8 battery_pct; // 0..100, 255 when absentu8 duty_cycle_pct; // 0..100, 255 when absentu16 tx_queue_depth;u16 peer_queue_depth;u32 store_horizon_s; // 0 when unknownu32 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.
Path block (present when FLAGS.PATH)
Section titled “Path block (present when FLAGS.PATH)”MeshCore-style source routing, 1 byte per hop:
u8 path_len; u8 path[path_len]; // path[i] = first byte of relay i's UIDPinned-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 senderu4 reserved; // 0 on transmit; receivers MUST ignoreu8 hint; // return-path hint bits (TRAIL ordering seed)u8 rack_rtr1; // first byte of the router UID a RACK targetsu8 trail_len; // 0..kMaxPathHops; number of TRAIL relay-UID bytesu8 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=UNKNOWNu8 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-unitsu4 rd_reserved; // 0 on transmitThe 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.
The AC byte (v2)
Section titled “The AC byte (v2)”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.
Tag8 trailer (present when FLAGS.TAGGED)
Section titled “Tag8 trailer (present when FLAGS.TAGGED)”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 keyhint ‖ ed25519_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.
Recovery and backfill (CHANSYNC)
Section titled “Recovery and backfill (CHANSYNC)”The transport assumption is “every packet may be lost; the stream must heal”.
- The sequencing router stamps each channel message with
SEQ3. - Clients remember
last_seq[chan,rtr]; nodes with cold state persist that cursor per mirrored stream. On receiving seqn > last+1, or on seeing anRTRSYNCmax 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); aftergap_grace(default 5 s) the client sendsCHANSYNCREQ listing up to 8 ranges:u8 op=REQ; u8 nranges; {u24 from; u24 to}[nranges]. - The router, or any relay mirror that has every requested message cached,
replays the stored messages in
SEQ3order withVIA_TCP-style suppression: replayed packets carry the original SRC/MSGID/SEQ so dedup still works for third parties that already have them. The hotChanStorering is checked first; if it no longer holds the full range, an already prepared warm-history index may serve the complete range fromlrclogrecord references. A request must not trigger a cold flash scan to build that index. If the serving node has the sender CTK, it reconstitutes theTAGGEDtrailer because Tag8 coversSEQ3as 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 across0xFFFFFE → 0;0xFFFFFFremains the unstamped sentinel and is never replayed as a real sequence. Dedup suppresses flood loops, not local mirror repair: if a node first saw aCHANMSGonly as transit, a later replay is still offered to any current local mirror, and theChanStoresequence check prevents duplicate IRC delivery. - 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 persistedlast_seq[chan,rtr]cursor to chase any missed remote stream ranges with ordinaryCHANSYNC. 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.)
ACK policy
Section titled “ACK policy”CHANMSGupstream (client→router) setsACK_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 withinack_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).DMuses explicitACKwith exponential backoff (1.5×, jittered ±25%).ACKpayload 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 onlineDM: the sender stores the original encoded frame and retries the same(SRC, MSGID)until theACKarrives 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 firstACKis 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 mutablePATHblock 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.HELLOuses the same retry discipline, but its signedWELCOMEis the terminal acceptance signal instead of a separateACK: the client stores the original signed HELLO and retries the same(SRC, MSGID, nonce_c)until WELCOME arrives or the retry budget expires.ADMINtelemetry queries and mutatingSET/REBOOTrequests use explicitACKwith the same packet shape and retry discipline; signedTELEM_REPLYorRESULTpackets are the eventual data responses. For mutating requests,ACKonly means the signed request was accepted for processing by the target router; a quorum-gated command is not applied until a signedRESULTreportsstatus=0.DCCCTLuses explicitACKwith the same saved-frame retry discipline asDM. 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 CTCPDCC SENDoffers intoDCCCTL OFFER.KEYREQ/KEYRESPis request/response without a separateACK: 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,LANEPROBEare 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_ms16preset 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, status8Status 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.
Delivery status to IRC clients
Section titled “Delivery status to IRC clients”IRC has no delivery receipts; we surface them without breaking clients:
- Default: silence on success, and on final failure the gateway emits
NOTICEfrom service user*lrc:not delivered: DM <nick>: <prefix>. - Power users:
/msg *lrc track onupgrades to per-messageNOTICEticks for ACK-backed DMs (queued,retry n,delivered, ornot delivered), and/msg *lrc statusdumps that user’s pending outbound ACK queue with per-message state (airborne, retry count, and summary). - Clients supporting IRCv3
echo-message+labeled-responseget 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).
MTU discipline
Section titled “MTU discipline”| 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.
Versioning
Section titled “Versioning”VT version bits 7..6 of byte 0:
0b00= v1 — the frozen layout pinned bytests/test_packet.cpp::packet_golden_bytes.FLAGS.PATH(bit 3) selects the source-routing Path block; bit 7 isEXT_ADDR, reserved and MUST be 0.0b01= v2 — adds the Edge Routing Block (§Edge Routing Block) and replaces byte 2THwithAC(§TheACbyte (v2)). In v2, bit 7 of FLAGS isFLAGS.ERB(its presence selects the ERB layout); the v1 Path block is unchanged and still selected byFLAGS.PATH. A v2 frame carries an ERB xor a Path block, never both. Byte 2 is alwaysACin 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.
The EXT_ADDR decision
Section titled “The EXT_ADDR decision”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 thatEXT_ADDRmust 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_ADDRvague across the freeze — this section is that explicit statement. What “re-reserved” means concretely: a future extended-address wire event is v3 (VTbits0b10), 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 setEXT_ADDR/ERBoutside their respective version’s defined meaning (already enforced bydecode()), 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.