# PROTOCOL.md — ChannelMessenger wire protocol, normative

**WireProtocol v1 · Envelope v1 · Handshake v1 · CryptoSuite v1 · RoutingProtocol v1**

Status: **draft complete, checkpoint 3** — §0–§9. Test vectors in `vectors/`.
`THREAT-WALKTHROUGH.md` is the remaining Assignment 01 deliverable.

**§6.5 (Handshake v1) was added on 2 September 2026.** The header above had named Handshake v1 as a v1
component since the first draft, and §4.2 had described its output, but no section defined the
exchange — §4.2's cross-reference pointed at §6.2, which is the encounter sequence and contains no
pairing step. `THREAT-WALKTHROUGH.md` predates §6.5 and does not yet cover packet types 11–13;
§6.5.8 carries that analysis in the meantime.

This document is normative. It must be sufficient for an Android engineer and an iOS engineer,
working from it alone, to produce byte-compatible implementations. Where it is ambiguous, that is a
defect in this document — record it in `SPEC.md` §6 rather than resolving it locally.

Governing documents, in precedence order: `DECISIONS.md`, `SPEC-REVISION.md`, `SPEC-ORIGINAL.md`.

---

## 0. Conventions

**Requirement levels.** MUST / MUST NOT / SHOULD / MAY per RFC 2119.

Constants marked **[policy]** are tunable and appear in §9's policy table. They are SHOULD, not MUST,
and are expected to change empirically (`DECISIONS.md` D1). An implementation MUST read them from
configuration rather than hardcoding them, and MUST NOT reject a peer for holding different values.

**Byte order.** All multi-byte integers on the wire are **big-endian** unless stated otherwise.

**Byte strings** are written as lowercase hex. `‖` denotes concatenation. `len(x)` is the length of
`x` in bytes.

**Fixed-width means fixed-width.** Fields specified with an exact byte count are never
length-prefixed, never variable, and never omitted. Absent optional values are encoded as their
defined zero value, not skipped.

**Hostile input.** Every parser MUST treat received data as hostile (`SPEC-ORIGINAL` §39). Length
bounds are checked *before* allocation. No force-unwrapping, no `try!`, no `unwrap()` on
network-derived or cryptographic input. A packet that fails any structural or cryptographic check is
discarded immediately and silently — implementations MUST NOT return distinguishing errors to a
peer, because doing so builds an oracle.

---

## 1. Canonical CBOR

The wire format is CBOR (RFC 8949). Determinism matters because two implementations that encode the
same data differently will fail to interoperate — and, before `DECISIONS.md` C1, would have failed
*authentication* with no obvious cause.

### 1.1 Encoding rules — normative

An ChannelMessenger encoder MUST produce, and a decoder MUST accept only, CBOR satisfying **RFC 8949
§4.2.1 core deterministic encoding**:

1. **Map key ordering: bytewise lexicographic on the encoded key.** Keys are sorted by comparing
   their encoded byte sequences directly.
2. **RFC 7049 canonical ordering is FORBIDDEN.** The older standard sorted by encoded length first,
   then bytewise. It produces a different order and some libraries still default to it. An
   implementation MUST NOT use length-first ordering. This is named explicitly because "canonical
   CBOR" is ambiguous between two incompatible standards, and choosing silently is the single most
   likely source of cross-platform divergence in this protocol.
3. **Shortest-form integers.** A value MUST be encoded in the smallest representation that holds it.
   `5` is `0x05`, never `0x18 0x05`.
4. **Definite lengths only.** Indefinite-length arrays, maps, byte strings and text strings MUST NOT
   be emitted, and MUST be rejected on decode.
5. **Integer map keys only.** Every map key defined by this protocol is an unsigned integer. Text
   string keys MUST NOT be used. This is both smaller and removes any question of collation.
6. **No floating point.** No half, single or double precision values appear anywhere in this
   protocol. A decoder MUST reject them.
7. **No tags** other than those this document defines. Currently: none.
8. **Canonical simple values.** `false`/`true`/`null` only; no undefined.

> **Measured, not assumed.** `vectors/03-canonical-cbor.json` demonstrates that for canonically
> encoded *unsigned integer* keys, the RFC 8949 and RFC 7049 orderings **always coincide** — CBOR's
> uint encoding is monotonic in both value and encoded length, so bytewise and length-first sorting
> agree. Rule 5 therefore does more than save bytes: it removes the 8949-vs-7049 hazard from every
> map this protocol defines. Rule 2 stays normative anyway, because it must hold for any future map
> that is not integer-keyed, and because an implementation should not be relying on a coincidence it
> has not checked.

### 1.2 Unknown fields

Forward compatibility (`SPEC-ORIGINAL` §35):

- A decoder MUST ignore unknown **optional** map keys and MUST preserve nothing about them — they are
  not re-emitted on forward, because re-emitting attacker-chosen bytes would let a sender smuggle a
  covert channel through relays.
- A decoder MUST reject a packet containing an unknown key in a **required capability** position
  (§ to be defined in checkpoint 2's CAPABILITY negotiation), failing negotiation gracefully rather
  than proceeding.
- Unknown `protocolVersion` values are handled per §2.2.

### 1.3 What canonical CBOR is *not* responsible for

Authentication does **not** depend on any of the above. The AAD is a fixed-layout byte concatenation
(§3), constructed without CBOR. A CBOR disagreement between two implementations therefore produces a
visible parse failure, not a silent universal authentication failure. See `DECISIONS.md` C1 for why
this matters more than any single rule in §1.1.

Compression, likewise, needs no canonical determinism: it sits inside the encryption boundary, so
only the format must be pinned (`DECISIONS.md` C6).

---

## 2. Envelope v1

The Envelope is the unit of delivery. It is byte-identical regardless of the path it travels —
direct, relayed, courier-carried, or across an internet gateway hop (`SPEC-ORIGINAL` §2). No
transport re-encrypts or rewrites it.

### 2.1 Fields

Encoded as a CBOR map with integer keys, per §1.1.

| Key | Name | Type | Width | In AAD | Mutable in transit |
|----:|------|------|-------|:------:|:------------------:|
| 1 | `protocolVersion` | uint | 1 byte | yes | no |
| 2 | `packetID` | bstr | 16 bytes | yes | no |
| 3 | `destinationToken` | bstr | 16 bytes | yes | no |
| 4 | `creationBucket` | uint | 4 bytes | yes | no |
| 5 | `expiresAfter` | uint | 1 byte | yes | no |
| 6 | `transitPolicy` | uint | 1 byte | yes | no |
| 7 | `payloadLength` | uint | 2 bytes | yes | no |
| 8 | `fragmentIndex` | uint | 1 byte | yes | no |
| 9 | `fragmentCount` | uint | 1 byte | yes | no |
| 10 | `totalLength` | uint | 4 bytes | yes | no |
| 11 | `nonce` | bstr | 12 bytes | no | no |
| 12 | `hopLimit` | uint | 1 byte | **no** | **yes** |
| 13 | `ciphertext` | bstr | variable | no | no |

Nothing else. In particular the Envelope carries no display name, device name, OS, model, build
number, account identifier, or any permanent identifier of any kind (`SPEC-ORIGINAL` §13, §15).

### 2.2 Field definitions

**`protocolVersion`** — `1` for this document. A node receiving a higher major version it does not
implement MUST discard the packet and MUST NOT relay it, because it cannot validate the structure it
would be forwarding.

**`packetID`** — 16 bytes from a cryptographically secure random source.

> **Duplicate suppression and inventory are keyed on `packetID ‖ fragmentIndex`, not on `packetID`
> alone.** All fragments of one message share a packet id, so a message-granularity table marks the
> whole message seen when its *first* fragment arrives and then rejects every sibling as a replay —
> fragmentation and duplicate suppression cannot both work at message granularity. **The relayed and
> suppressed unit is a fragment.** Reassembly happens only at the destination (§3.2). Found by an
> end-to-end test after both mechanisms were separately correct. It MUST NOT be derived from the
content, the sender, the recipient, or the time; a derived ID would be a linkable fingerprint. 128
bits makes collision negligible across any plausible mesh size.

*Privacy note:* `packetID` is necessarily constant along a packet's whole journey — that is what
makes duplicate suppression work. A global observer could therefore trace one packet across the
network. This is accepted and is already excluded from the threat model (`SPEC-ORIGINAL` §32, global
radio traffic analysis).

**`destinationToken`** — 16 bytes, derived per checkpoint 2. Distinct from `presenceToken`
(`DECISIONS.md` C3): a relay that heard a presence beacon MUST NOT be able to match it against an
envelope it is carrying.

**`creationBucket`** — the sender's creation time as `floor(unixTime / 600)`, i.e. in 600-second
buckets, matching the token epoch length so it reveals nothing the token does not already.

It exists because expiry alone is insufficient: if lifetime were measured only from each hop's
receipt time, a packet could be kept alive indefinitely by repeated relaying. An absolute creation
bucket bounds total lifetime regardless of path.

**`expiresAfter`** — lifetime in 600-second buckets **relative to `creationBucket`**, not an absolute
timestamp. Relative encoding avoids publishing the sender's clock. A packet is expired when
`nowBucket > creationBucket + expiresAfter`.

Range 1–255 (up to ~42.5 h). Relays SHOULD reject values above `MAX_RELAY_LIFETIME` **[policy]**,
default 144 buckets (24 h). Note this bounds *relay* retention only; a sender holds its own
undelivered outbound messages indefinitely (`DECISIONS.md` A4).

**`transitPolicy`** — sender-declared routing restriction.

| Value | Meaning |
|------:|---------|
| 0 | **Local only.** MUST NOT traverse an internet gateway hop. **Default.** |
| 1 | Any transport permitted, including gateway. |

Binary by design, not a bitfield: every additional bit is another feature relays can sort traffic by
(`DECISIONS.md` D5). Default-restrictive so the bulk of traffic sits in the restrictive class and
*permitting* gateway transit is the deliberate act, rather than the restrictive flag marking out
exactly the traffic someone cared about.

This field is **advisory**. Relays share no key with the sender and cannot be compelled to honour it.
It is included in the AAD so that *tampering* is detectable end-to-end, but a relay that simply
ignores it leaves no trace. Implementations MUST NOT present this to users as a guarantee.

**`payloadLength`** — length of `ciphertext` in bytes, including the AEAD tag. MUST equal
`len(ciphertext)`; a mismatch is a discard. Because of mandatory bucketed padding (§5.3)
this takes one of a small set of values, not an arbitrary one.

**`fragmentIndex`, `fragmentCount`, `totalLength`** — fragmentation, per `SPEC-ORIGINAL` §13.

**Fragmentation is decided before sealing, and never afterwards.** `fragmentCount` is inside the AAD
(§3.2), so a sender must know how many fragments there will be at the moment it encrypts. It follows
that **a relay MUST NOT re-fragment** an envelope to fit a smaller transport MTU: it holds no key and
cannot recompute the tag. Fragmentation is the sender's decision for the whole journey.
Unfragmented envelopes use `fragmentIndex = 0`, `fragmentCount = 1`, `totalLength = payloadLength`.

Fragmentation is the mechanism most likely to undo the size cap, so (`DECISIONS.md` C7):

- `totalLength` MUST be validated against `MAX_ENVELOPE_BYTES` **[policy]** (default 32768)
  **before** any reassembly buffer is allocated.
- `fragmentCount` MUST NOT exceed `MAX_FRAGMENTS` **[policy]** (default 16).
- All fragments of one message MUST carry identical `packetID`, `destinationToken`, `creationBucket`,
  `expiresAfter`, `transitPolicy`, `totalLength`, **`fragmentCount` and `nonce`**. A fragment disagreeing with
  its siblings on any of these MUST cause the entire partially-reassembled message to be discarded —
  not merely that fragment rejected. A mismatch means someone is injecting, and keeping the partial
  buffer would let them keep trying.

  *`fragmentCount` was added to this list after implementation: `fragmentIndex` is meaningless except
  relative to it, and a reassembly slot table is sized from it, so disagreement is not survivable.*

  *`nonce` was added after an external review. The AEAD runs once over the whole plaintext (§3.2), so a
  message has exactly one nonce — but nothing checked that siblings carried the same one. A receiver
  would take the nonce from whichever envelope completed the set, so altering it on any single fragment
  silently destroyed a correctly reassembled message.*
- A reassembly buffer MUST be bounded in count **[policy]** and MUST expire with the envelope.

**`nonce`** — 12 bytes, unique per encryption under a given key (`SPEC-ORIGINAL` §11). Generated from
a CSPRNG. A receiver MUST reject a `(key, nonce)` pair it has already accepted.

It is **excluded from the AAD**: it is an input to the AEAD itself, and including it would be
circular.

**`hopLimit`** — remaining hops. Decremented by one at each relay. A packet with `hopLimit == 0` MUST
NOT be forwarded.

**This is the only field mutable in transit, and it is therefore excluded from the AAD**
(`DECISIONS.md` C4) — including it would break the authentication tag at the first hop.

The security consequence must be stated rather than glossed: `hopLimit` is **unauthenticated and
adversarially mutable**. A hostile relay can decrement it faster than it should (silently killing
delivery) or raise it (amplifying network load). No pairwise key can prevent this, because relays
share no secret with the sender. The defences are entirely local: each node enforces its own
`MAX_ACCEPTED_HOP_LIMIT` **[policy]** and MUST clamp or reject anything above it — otherwise one
permissive build injecting `hopLimit = 200` conscripts every honest node into carrying it.

**`ciphertext`** — the AEAD output, ciphertext ‖ 16-byte tag. Opaque to every party except the
destination. Its plaintext structure is defined in checkpoint 2.

### 2.3 Size bound

A complete encoded Envelope MUST NOT exceed `MAX_ENVELOPE_BYTES` **[policy]**, default 32768. A
receiver MUST check the framed length against this bound before allocating, and MUST abandon a
transfer that exceeds it rather than truncating.

---

## 3. AAD construction

**The AAD is a fixed-layout byte concatenation. It is NOT a CBOR re-encoding of the header.**

This is `DECISIONS.md` C1, and it is the most important structural decision in this document. If AAD
were "the canonical CBOR encoding of the header," two implementations disagreeing about map key
ordering would compute different tags, and *every* message between them would fail authentication —
surfacing as "authentication failed," which sends a developer to inspect the cryptography, where
nothing is wrong. With a fixed layout, a CBOR disagreement produces a visible parse error instead,
and the security-critical path contains no ambiguity at all.

### 3.1 Layout — exactly 47 bytes

Constructed in this order, with no separators, no length prefixes, and no CBOR:

```
offset  size  field
------  ----  ---------------------------------------------------
     0     1  protocolVersion          uint8
     1    16  packetID                 bstr
    17    16  destinationToken         bstr
    33     4  creationBucket           uint32 big-endian
    37     1  expiresAfter             uint8
    38     1  transitPolicy            uint8
    39     2  payloadLength            uint16 big-endian
    41     1  fragmentIndex            uint8
    42     1  fragmentCount            uint8
    43     4  totalLength              uint32 big-endian
------  ----  ---------------------------------------------------
             47 bytes total, always
```

**Excluded and why:**

- `hopLimit` — mutable in transit (§2.2). Including it would break the tag at the first hop.
- `nonce` — **already authenticated implicitly, so including it would add nothing.**
  ChaCha20-Poly1305 derives its Poly1305 one-time key from the cipher keyed with
  (key, nonce). Alter the nonce and that key changes, so the tag cannot verify. An
  earlier version of this document said including the nonce "would be circular",
  which is simply wrong — an AAD is just bytes and the nonce could sit in it. The
  reason it need not is authentication, not circularity. Corrected after an
  external review flagged the rationale; `altering_the_nonce_breaks_authentication`
  in the test suite is the proof, and it fails loudly if this ever stops holding.
- `ciphertext` — authenticated by the AEAD construction itself.

The AAD length is constant at 47 bytes for every envelope, fragmented or not. There is no
length-prefix ambiguity and no canonicalisation step. An implementation that produces 47 bytes in
this order is byte-identical to every other conforming implementation, in any language.

### 3.2 Fragmented messages have exactly one AAD

The AEAD is computed **once, over the whole plaintext**, before fragmentation. So a fragmented
message has one AAD, not one per fragment, and it MUST be constructed canonically:

```
fragmentIndex  = 0
fragmentCount  = the real count, N
payloadLength  = totalLength          (the whole ciphertext, not this fragment's slice)
```

Every other field is as normal. A receiver reassembles first, then builds this AAD, then verifies.

**Consequence, and it must be stated rather than discovered.** A fragment's *own* `fragmentIndex` and
its *own* `payloadLength` are therefore **not authenticated** — they are reassembly instructions, not
protected content, in the same category as `hopLimit` (§2.2).

An attacker who can inject fragments can therefore corrupt reassembly and cause the AEAD tag to fail,
destroying the message. **This is a denial of service, not a break** — the content stays confidential
and forgery remains impossible. And it grants no new capability: anyone positioned to inject fragments
is already positioned to simply drop them, which is equally effective and simpler. Recorded in
`THREAT-WALKTHROUGH.md` §8 rather than mitigated, because per-fragment authentication would cost bytes
on every packet to defend against an attacker who has a cheaper attack available.

### 3.3 Construction is independent of parsing

An implementation MUST build the AAD from the *decoded field values*, not from the received bytes.
Two conforming encoders produce identical CBOR, so in practice these agree — but binding to decoded
values means a non-canonical encoding from a buggy peer fails cleanly at the parse step rather than
producing a valid-looking tag over malformed bytes.

### 3.4 Worked example

Fully specified test vectors are in `vectors/`. Illustratively, an unfragmented envelope with:

```
protocolVersion   = 1
packetID          = 0102030405060708090a0b0c0d0e0f10
destinationToken  = a1a2a3a4a5a6a7a8a9aaabacadaeafb0
creationBucket    = 2934496          (0x002cc6e0)
expiresAfter      = 144              (0x90)
transitPolicy     = 0                (local only)
payloadLength     = 272              (0x0110)
fragmentIndex     = 0
fragmentCount     = 1
totalLength       = 272              (0x00000110)
```

produces the AAD:

```
01
0102030405060708090a0b0c0d0e0f10
a1a2a3a4a5a6a7a8a9aaabacadaeafb0
002cc6e0
90
00
0110
00
01
00000110
```

= `010102030405060708090a0b0c0d0e0f10a1a2a3a4a5a6a7a8a9aaabacadaeafb0002cc6e090000110000100000110`

47 bytes. Note `hopLimit` appears nowhere in it.

*(Both values above were machine-computed, not written by hand — the first draft of this example had
an arithmetic error in `creationBucket` and a dropped byte in the concatenation. Every vector in
`vectors/` is generated, never transcribed.)*

---

---

## 4. Identity, secrets and token derivation

### 4.1 Primitives — CryptoSuite v1

| Purpose | Algorithm |
|---|---|
| Identity signing | Ed25519 |
| Key agreement | X25519 |
| Key derivation | HKDF-SHA256 |
| Token derivation | HMAC-SHA256, truncated |
| AEAD | ChaCha20-Poly1305, 96-bit nonce, 128-bit tag |
| Hash | SHA-256 |

No primitive outside this table appears anywhere in the protocol. Never invent cryptography
(`SPEC-ORIGINAL` §11). A future CryptoSuite v2 replaces this table wholesale; it does not add
alternatives to it, because negotiable cipher suites are a downgrade-attack surface.

### 4.2 Pairing output

A completed Handshake v1 (§6.5) leaves both parties holding:

```
sharedSecret     32 bytes   X25519(myEphemeralPriv, theirEphemeralPub)
pairingSalt      32 bytes   SHA-256(idPubLow ‖ idPubHigh ‖ pairingNonce)
generation       uint32     0 at first pairing, +1 on each re-pair
idPubLow         32 bytes   the lexicographically smaller of the two Ed25519 identity keys
idPubHigh        32 bytes   the lexicographically larger
```

`idPubLow` / `idPubHigh` give both sides an identical, canonical ordering of the pair without either
having to be designated "initiator." This ordering is what makes §4.4's direction byte work, and it is
also what lets both sides of §6.5's handshake build a byte-identical signed transcript without first
agreeing a role.

> **This section used to point at §6.2, which does not define a handshake.** §6.2 is the encounter
> sequence and contains no pairing step. The exchange that actually produces the four values above is
> §6.5, added later; until it existed, both platform apps filled the gap with a placeholder that set
> `sharedSecret = idPubLow ‖ idPubHigh` — the two public keys, which travel in the clear in the pairing
> code. A specification that names an output without defining the thing that produces it is a defect in
> this document, and this is what one costs.

### 4.3 Routing secret

```
routingSecret = HKDF-SHA256(
    ikm  = sharedSecret,
    salt = pairingSalt,
    info = "mesh/routing-v1" ‖ generation      (uint32 big-endian)
)                                                      → 32 bytes
```

Bumping `generation` on re-pairing invalidates every previously derived token at once
(`DECISIONS.md` C5), while conversation history — keyed separately — survives. Deleting a contact
destroys `routingSecret`, after which anything still in flight to that contact is permanently
undecryptable, including by us.

### 4.4 Presence and destination tokens

Two tokens, derived from the same secret under **different labels**, so that no party can link a
presence beacon to an envelope in flight (`DECISIONS.md` C3).

```
epoch  = floor(unixTime / EPOCH_SECONDS)               [policy] default 600

dir(P) = 0x00 if P's identity key is idPubLow
         0x01 if P's identity key is idPubHigh

presenceToken(P, epoch)    = HMAC-SHA256(routingSecret,
                               "mesh/presence-v1" ‖ dir(P) ‖ epoch)[0..16]

destinationToken(P, epoch) = HMAC-SHA256(routingSecret,
                               "mesh/destination-v1" ‖ dir(P) ‖ epoch)[0..16]
```

`epoch` is encoded as uint64 big-endian. Both tokens are the **first 16 bytes** of the HMAC output.
128 bits is ample against guessing and keeps the Envelope small.

**The direction byte is not optional.** Without it both parties would derive the *same* presence
token from a shared secret, and a relay hearing the same token emitted by two different devices
would learn immediately that those two devices are a pair. `dir(P)` identifies the party the token
*belongs to*: Carol beacons with `dir(Carol)`, and Alice computes Carol's expected token using
`dir(Carol)`. An envelope addressed to Carol carries `destinationToken(Carol, epoch)`.

**Consecutive tokens are unlinkable.** Distinct epochs produce independent HMAC outputs; an observer
sees uncorrelated 16-byte values every ten minutes (`SPEC-REVISION` §1.7).

### 4.5 Message key

Token derivation and content encryption MUST use **independent** key material. `routingSecret` is
handled by relays' worth of machinery — it is the input to values that appear in the clear on every
packet — and it must not be recoverable from, or a step toward, the key that protects plaintext.

```
messageKey = HKDF-SHA256(
    ikm  = sharedSecret,
    salt = pairingSalt,
    info = "mesh/message-v1" ‖ generation      (uint32 big-endian)
)                                                      → 32 bytes
```

Derived from `sharedSecret` directly, in parallel with `routingSecret` (§4.3) rather than from it.
Compromise of a routing secret therefore discloses routing metadata only, and never content.

`generation` participates identically, so re-pairing rotates both at once (`DECISIONS.md` C5).

**AEAD binding.** `ChaCha20-Poly1305(key = messageKey, nonce = envelope.nonce, aad = §3 AAD)`.
The nonce is carried in the Envelope and MUST be unique per key; a receiver MUST reject a
`(key, nonce)` pair it has already accepted.

**This is CryptoSuite v1's static composition.** A Double Ratchet replaces this derivation wholesale in
a later suite (`SPEC-ORIGINAL` §11) — it is not layered on top, and v1 must not be described as
providing forward secrecy, which it does not.

### 4.6 Epoch acceptance and clock skew

A receiver MUST accept tokens for the **previous, current and next** epoch — a ±1 window. With the
default 600-second epoch this tolerates just under 10 minutes of skew in either direction, and gives
a token a maximum useful life of 30 minutes.

Implementations MUST NOT depend on network time, which is unavailable by definition in the target
environment. Device real-time clocks are the only source. A device whose clock has drifted more than
one epoch will silently fail to be found; §7's diagnostics surface this rather than leaving it as a
mystery.

Precomputing three epochs for every contact is 3N HMAC operations per epoch — negligible at any
plausible contact count.

**Replay is possible and accepted.** An observer can record and rebroadcast a presence token within
its 30-minute window, making peers believe a contact is nearby. The cost is a wasted route query;
delivery still requires the real holder to respond. Presence tokens are hints, never authorisation.

### 4.7 Service identifier

```
ChannelMessenger service UUID:  FB985D30-F439-4BD2-90F8-6C1BA2A23394
Payload characteristic:    F9BF4B77-F212-4B11-B04C-90CDE4B5CA5F
```

Protocol-level, not an application detail. A background BLE scan on iOS cannot
use a wildcard — it must name the service UUIDs it is interested in — so every
node has to look for this exact value. **Changing it partitions the network**
into two populations that cannot see each other, which is why it is pinned here
rather than left to each platform.

It carries no information: it is a fixed random value, identical on every
device, and reveals only that a device runs this protocol. That is already
disclosed by participating at all (`THREAT-WALKTHROUGH.md` §2).

### 4.8 The beacon fan-out problem, and why beacons carry no tokens

Presence tokens are **pairwise**. Carol with fifty contacts has fifty distinct presence tokens per
epoch. Broadcasting all of them would be a battery and airtime problem, and the *cardinality* of the
set would itself be a fingerprint even though each value is opaque.

So beacons carry no tokens at all. Discovery splits in two:

- **Advertisement** — service UUID plus a **random ephemeral ID**, rotated per `ADVERT_ROTATE`
  **[policy]** (default 900 s). It says only "a node running this protocol is here." It is not
  derived from any secret, identity or contact, and it is unlinkable across rotations
  (`SPEC-ORIGINAL` §15).
- **After connection** — the two nodes exchange presence tokens in a `PRESENCE` packet (§6.3), where
  the payload is a unicast frame rather than a broadcast and size is far less constrained.

This keeps the broadcast channel constant-size regardless of contact count, and confines pairwise
material to connections that already happened.

---

## 5. Payload structure, padding and compression

### 5.1 Plaintext layout

The plaintext encrypted into `ciphertext` is:

```
plaintext = canonical-CBOR( PayloadMap ) ‖ zeroPadding
```

`PayloadMap` keys:

| Key | Name | Type | Notes |
|----:|------|------|-------|
| 1 | `senderIdentity` | bstr 32 | Ed25519 identity public key. **Sealed sender** — visible only to the destination. |
| 2 | `sequence` | uint | Monotonic per sender per conversation (`SPEC-ORIGINAL` §21). |
| 3 | `sentAt` | uint | Sender's wall clock, seconds. Safe here; never in the Envelope. |
| 4 | `payloadType` | uint | 1 text · 2 delivery receipt · 3 read receipt · 4 policy update · 5 fragment continuation · 6 attachment chunk (§5.6) · 7 contact card (§5.7) |
| 5 | `compressed` | uint | 0 none · 1 Brotli with `CompressionDictionary v1`. |
| 6 | `policy` | map | Sender's current settings (§5.5). |
| 7 | `body` | bstr | UTF-8 text, or a receipt structure, per `payloadType`. |
| 8 | `groupId` | bstr 16 | *Optional.* Present for group messages (§5.2). |
| 9 | `groupDigest` | bstr 8 | *Optional.* Membership digest, so divergence is detectable (§5.2). |

`compressed` lives **inside** the encryption boundary, never in the header — otherwise it would be
one more bit for relays to sort traffic by (`DECISIONS.md` C6).

### 5.7 Contact card (`payloadType 7`)

The sender's own card, for the receiver to show beside their messages (`DECISIONS.md` J84 §4).
`body` is UTF-8 JSON:

```
{ "v": 1, "name": "<display name>", "stock": "<glyph>" | absent, "jpeg": "<base64, ≤ 16 KiB decoded>" | absent }
```

Sent once after a pairing completes and again whenever the card changes; a receiver that already
holds the same card (same bytes) ignores it. The picture, when present, is a square JPEG no larger
than 128 px and 16 KiB, so the whole payload fits the largest padding bucket (32 KiB, §5.3). A card is never filed as a message and never
notified. The receiver keeps its own local name for the contact; the card's name is shown only where
the receiver has not chosen one, and the picture is shown unless the receiver picked their own.

### 5.2 Groups — pairwise fan-out

Groups are **N separate envelopes, one per recipient, each under its own pairwise key**. There is no
group key and no group destination token (`DECISIONS.md` G2).

**This is a privacy requirement, not an implementation shortcut.** A shared group key implies a shared
destination token, so every member would carry the *same* rotating address — and a relay hearing one
token from several devices would learn those people are in a group together. That is precisely the
correlation the direction byte prevents for pairs (§4.4), reappearing at group scale. With fan-out a
relay sees N unrelated messages carrying N unrelated addresses and cannot tell they are one
conversation.

`groupId` and `groupDigest` live **inside the ciphertext**. Neither ever appears in an Envelope; an
outer group identifier would undo everything above.

- `groupId` — 16 random bytes chosen at group creation. It is a threading key for recipients, not an
  address, and it is never used for routing.
- `groupDigest` — the first 8 bytes of SHA-256 over the sender's sorted member identity keys. It lets
  a recipient detect that its view of membership differs from the sender's. With no server, membership
  is eventually consistent; clients MUST surface a divergence rather than silently dropping someone
  from a conversation they believe they are in.

**`MAX_GROUP_MEMBERS`** **[policy]**, default **6**, MUST be enforced rather than advised. Fan-out
costs N× bandwidth and N× relay storage, and courier retention is where that bites — six rather than
sixteen nearly halves the worst case exactly where it hurts. The cost is affordable at all only
because the pairing model caps group size by construction: every member requires an in-person exchange
or a deliberate invite, so large groups cannot form.

**Status: fields reserved, implementation deferred past v1** (`DECISIONS.md` G2). A v1 implementation
MUST reject `groupId` on receipt rather than partially honouring it.

A sender MUST assign `sequence` per group, independently of its pairwise sequences.

### 5.3 Padding — mandatory

After CBOR encoding, the plaintext is zero-padded up to the smallest bucket that fits it:

```
PADDING_BUCKETS  [policy] = 256, 1024, 4096, 16384, 32768 bytes
```

A decryptor decodes the CBOR item and then **MUST verify every remaining byte is `0x00`**, rejecting
the message otherwise. Trailing non-zero bytes are a covert channel; there is no legitimate reason
for them to exist. No explicit length field is needed, because a CBOR decoder terminates at the end
of the item.

Padding is not optional and is not a user setting. `payloadLength` is visible to every relay, and
unpadded length distinguishes "ok" from a paragraph — that is a side channel whether or not
compression is in use.

### 5.4 Compression — optional, inside the padding

Compression MAY be applied to `body` before the map is encoded. It is worth understanding what it
does and does not buy:

- At typical message lengths (50–200 bytes) generic compressors have fixed overhead and too little
  redundancy to exploit; deflate frequently **expands** short text. A preset dictionary is what
  rescues this range, so `CompressionDictionary v1` is a shipped, versioned constant — two devices
  with different dictionaries cannot read each other's compressed messages.
- Because padding rounds to buckets, compression only helps when it moves a message down a bucket.
  Its real value is courier storage, where roughly halving stored size meaningfully increases how
  many envelopes a relay can carry.
- An implementation MUST compare compressed and uncompressed sizes and MUST emit whichever is
  smaller, setting `compressed` accordingly.

**Decompression is a hostile-input surface.** A small ciphertext can expand without bound. An
implementation MUST enforce `MAX_DECOMPRESSED` **[policy]** (default 32768) **during** streaming
decompression, aborting the moment the bound is crossed rather than checking afterwards, and MUST
fail closed. This is `SPEC-ORIGINAL` §39's "strict length bounds before allocation" applied to
compression.

**Forward rule.** Authored and received content MUST NOT share a compression context. Compressing a
quoted reply together with the reply text would reconstruct the CRIME attack properly — an attacker
who can influence quoted content learns about the secret content from the compressed length. This
matters the day someone adds quoting or forwarding, which is why it is written down now.

Compression requires no canonical determinism: it sits inside the encryption boundary, so only the
format is pinned. Two implementations producing different compressed bytes for the same input is
harmless, because each decompresses correctly (`DECISIONS.md` C6).

### 5.5 Policy piggybacking

The `policy` map carries the sender's current settings on every message, so peers converge without a
dedicated packet type and without adding an observable traffic pattern (`DECISIONS.md` D6).

| Key | Setting | Stricter direction |
|----:|---------|--------------------|
| 1 | `maxHopLimit` | **lower** |
| 2 | `maxRelayLifetime` | **lower** |
| 3 | `minPaddingBucket` | **higher** |
| 4 | `allowGatewayTransit` | **false** |
| 5 | `disappearAfter` | **lower** (0 = never, which is the *weakest*) |
| 6 | `allowCompression` | **false** |

**`min()` is not the strict operator.** For padding, stricter is *larger* — an implementation that
naively minimises every field silently degrades padding to the weakest setting on the conversation.
The direction column above is normative and no value may be reconciled by inference.

Reconciliation rules:

- The effective value for a message is the stricter of the sender's own setting and the last policy
  received from the peer.
- **Convergence is lazy.** A tightened setting takes effect for the peer only after they receive one
  message carrying it. It is not retroactive, and the UI MUST NOT imply that it is.
- **When a peer's policy is unknown** — fresh pairing, nothing received yet — the strictest value in
  the table applies. Fail closed, relax as information arrives.

Relay mode (Off / Contacts Only / Community Mesh) and push enrollment are **not** in this table.
They govern what a device does for strangers, or its own exposure; they are not properties of a
conversation and have no counterpart to reconcile against.

---

## 6. Packet types

All packets share a two-byte frame header: `packetType` (uint8) and `flags` (uint8, reserved, MUST
be 0 in v1 and MUST be ignored on receipt). The body is canonical CBOR per §1.

Size bounds marked **[policy]** are enforced before allocation. Any packet exceeding its bound is
discarded and the connection SHOULD be closed — an oversized frame is either a bug or an attack, and
neither is worth continuing.

### 6.1 Summary

| # | Type | Direction | When sent | Bound | What a relay learns |
|--:|------|-----------|-----------|------:|---------------------|
| 1 | `HELLO` | both | first frame of every encounter | 64 B | protocol version, nothing else |
| 2 | `CAPABILITY` | both | immediately after HELLO | 128 B | transport class bits, storage class |
| 3 | `PRESENCE` | both | after CAPABILITY | 2 KB **[policy]** | a set of opaque 16-byte tokens |
| 4 | `ROUTE_QUERY` | broadcast | sender seeks a destination | 96 B | a query ID, a token, a hop budget |
| 5 | `ROUTE_RESPONSE` | reverse path | destination or cache answers | 96 B | that *some* node answered |
| 6 | `INVENTORY` | both | after PRESENCE | 4 KB **[policy]** | fragment keys and destination tokens held |
| 7 | `WANT` | both | after INVENTORY | 4 KB **[policy]** | which packet IDs a peer lacks |
| 8 | `ENVELOPE` | both | in response to WANT | 32 KB **[policy]** | §2's fields only |
| 9 | `ACK` | both | on accepting an ENVELOPE | 64 B | a packet ID was accepted |
| 10 | `GOODBYE` | both | end of encounter | 32 B | the encounter ended |
| 11 | `PAIR_OFFER` | initiator | first flight of a pairing (§6.5) | 96 B | an X25519 public key and a nonce |
| 12 | `PAIR_ACCEPT` | responder | answering a `PAIR_OFFER` | 160 B | the same, plus a signature it cannot attribute |
| 13 | `PAIR_CONFIRM` | initiator | completing a pairing | 96 B | a signature it cannot attribute |

**Types 11–13 are Handshake v1 (§6.5), not part of the encounter sequence.** They share this frame
space because they cross the same link and a receiver has to demultiplex them from the same byte
stream; §6.2 never sends or expects one. What an observer of a pairing *can* do with them, beyond the
table above, is set out in §6.5.8 — including the one disclosure that is real.

**No packet at any layer carries a device name, OS, model, build number, account identifier, phone
number, or permanent device identifier** (`SPEC-ORIGINAL` §15). Capabilities are protocol bits, never
named platform characteristics.

### 6.2 Encounter sequence

```
HELLO ─▶ CAPABILITY ─▶ PRESENCE ─▶ INVENTORY ─▶ WANT ─▶ ENVELOPE… ─▶ ACK… ─▶ GOODBYE
```

**This sequence contains no pairing step, and never did.** Pairing is Handshake v1, defined in §6.5;
two nodes that have not paired have nothing to say to each other beyond `HELLO`/`CAPABILITY`. §4.2
pointed here for the definition of that handshake for as long as no such definition existed anywhere.

Encounters are deliberately short (`SPEC-ORIGINAL` §17). An implementation MUST be able to abandon at
any point and resume later without loss — the transport window may be ten seconds in a corridor.
Every stage is idempotent and no stage depends on completing the one after it.

### 6.3 Definitions

**`HELLO`** — `{1: protocolVersion}`. Sent first, always. A version mismatch ends the encounter
immediately with no further disclosure.

**`CAPABILITY`** — `{1: transportClasses (uint bitfield), 2: storageClass (uint), 3: roleFlags (uint)}`.

`roleFlags` bit 0 marks a **persistent node** (`DECISIONS.md` A3) — always-on, high storage — which
raises its preference in route costing. Bit 1 marks **gateway-capable**, set only when the operator
has opted in. Bit 2 marks a **wake beacon** (§7.8). Remaining bits reserved.

Suppressed when: the peer's HELLO failed.

**`PRESENCE`** — `{1: [tokens]}`, a list of 16-byte presence tokens the sender is currently
advertising, for the current epoch only.

> **The list MUST always contain exactly `PRESENCE_SET_SIZE` entries** **[policy]**, default 32.
> A node with fewer contacts pads with values drawn from a CSPRNG, indistinguishable from real
> tokens. A node with more sends a random sample, re-sampled each encounter so every contact is
> advertised eventually.
>
> Without this the list length *is* the sender's contact count, disclosed to anyone who connects —
> the same fingerprint §4.6 avoided on the broadcast channel, reintroduced on the unicast one. Found
> while writing `THREAT-WALKTHROUGH.md`; see its §7.

Sent after CAPABILITY, never broadcast (§4.8). A receiver caches each token as
`token → peer, heardAt, linkQuality` with lifetime `PRESENCE_CACHE_TTL` **[policy]** (default 900 s),
and MUST NOT retain any association beyond that. A relay caching these does **not** learn who they
belong to; that is the entire point (`SPEC-REVISION` §1.6).

Nodes MUST NOT build or retain global reachability tables of the form "I can reach X" — that leaks
topology (`SPEC-REVISION` §1.5).

**`ROUTE_QUERY`** — `{1: queryID (8 B), 2: presenceToken (16 B), 3: hopBudget (uint), 4: expiresAt (uint)}`.

Carries the **presence** token, so it can be answered from the caches built by `PRESENCE`. Relays
record reverse-path state `queryID → receivedFrom, hop, expires` for `QUERY_STATE_TTL` **[policy]**
(default 10 s) and rebroadcast with `hopBudget − 1`. A `queryID` already seen MUST be dropped, not
rebroadcast — duplicate suppression, not the hop budget, is what bounds flood cost.

**`ROUTE_RESPONSE`** — `{1: queryID, 2: minLinkQuality (uint8), 3: accumulatedPenalty (uint16),
4: hopCount (uint8), 5: mac (16 B)}`.

Travels the reverse path only. **It does not contain the path** (`DECISIONS.md` D3). The originator
learns next hop, coarse hop count and enough to compute a cost — never the identities or ordering of
intermediate nodes.

`minLinkQuality` and `accumulatedPenalty` are carried **separately** so the weak-link term is applied
exactly once, at the originator (§8.3). Each forwarding node updates both per §8.4.

`mac = HMAC-SHA256(routingSecret, "mesh/route-response-v1" ‖ queryID)[0..16]`, computed by the
destination. It proves that a party holding the pairwise secret answered — relays can neither forge
nor verify it.

**The MAC covers `queryID` only.** The quality and penalty fields are accumulated hop by hop and are
therefore mutable in transit, exactly like `hopLimit` (§2.2). They are **unauthenticated hints**: a
hostile relay can understate cost to attract traffic or overstate it to repel traffic, and no
pairwise key can prevent this. What the MAC does guarantee is that the *destination is real* — an
attacker cannot manufacture a route to a party whose secret it does not hold.

**`INVENTORY`** — `{1: [[packetID, destinationToken], …]}`.

Offers what the sender is carrying. Destination tokens are included so a peer can recognise packets
addressed to it without fetching everything — safe precisely because §4.4 derives destination tokens
separately from presence tokens, so harvesting them cannot be correlated against any beacon.

Entries capped at `MAX_INVENTORY_ENTRIES` **[policy]** (default 128). A future version MAY substitute
a compact probabilistic set (`SPEC-ORIGINAL` §17); v1 uses an explicit list for determinism.

**`WANT`** — `{1: [packetID, …]}`. The subset the sender lacks and will accept, capped by
`MAX_PACKETS_PER_ENCOUNTER` **[policy]**. A node MUST NOT request packets it has no room to store.

**`ENVELOPE`** — §2, unchanged, byte-identical regardless of transport.

**`ACK`** — `{1: packetID, 2: accepted (bool)}`. A transport-level acknowledgement that a relay took
custody. It is **not** a delivery receipt — delivery receipts are end-to-end, encrypted, and travel
as `payloadType 2` inside an Envelope of their own.

**`GOODBYE`** — `{1: reason (uint)}`. Reasons: 1 complete · 2 storage full · 3 rate limited ·
4 going out of range. Advisory; a peer disappearing without one is normal and MUST be handled
identically.

### 6.4 Abuse limits

Per encounter, enforced locally (`SPEC-ORIGINAL` §18):

| Limit | Default **[policy]** |
|---|---|
| `MAX_PACKETS_PER_ENCOUNTER` | 64 |
| `MAX_BYTES_PER_ENCOUNTER` | 1 MB |
| `MAX_RELAY_STORAGE` | 25–100 MB, user-configurable |
| `MAX_ENVELOPE_BYTES` | 32768 |
| `MAX_RELAY_LIFETIME` | 144 buckets (24 h) |
| `MAX_ACCEPTED_HOP_LIMIT` | 8 |
| `MAX_DISCOVERY_HOPS` | 3 |

`MAX_ACCEPTED_HOP_LIMIT` and `MAX_DISCOVERY_HOPS` differ deliberately and are not in conflict:
discovery is bounded tightly because it floods, while an already-routed or courier-carried envelope
may legitimately pass through more nodes over its lifetime.

These bound a relay's exposure, not a sender's own outbox — a sender holds undelivered messages
indefinitely (`DECISIONS.md` A4).

### 6.5 Handshake v1 — the pairing key agreement

§4.2 describes what a completed Handshake v1 *leaves you holding*. This section defines the exchange
that produces it. Until it was written, nothing did: the header claimed Handshake v1 as a shipped
component, §4.2 pointed at §6.2, and §6.2 is the encounter sequence, which contains no pairing step at
all. Both platform apps consequently shipped a placeholder that set
`sharedSecret = idPubLow ‖ idPubHigh` — **the two public keys**, both of which travel in the clear in
the pairing code. Anyone who photographed a QR held every secret that followed from it. This section
exists so that no implementer has to guess again.

The construction is **signed ephemeral Diffie–Hellman**, station-to-station in shape. Nothing in it is
novel and nothing may be (`SPEC-ORIGINAL` §11): X25519 for the agreement, Ed25519 for the
authentication, SHA-256 for the salt — CryptoSuite v1 (§4.1) unchanged.

#### 6.5.1 What the handshake does and does not authenticate

**Authentication comes from the pairing code, not from this exchange.** Before a handshake begins,
each party already holds the other's long-term Ed25519 identity public key, carried over a channel the
users chose — a QR held up for three seconds, an NFC tap, or a blob split across two messaging
channels (`DECISIONS.md` E1, J15). Handshake v1's entire job is to bind a *fresh* X25519 agreement to
those two already-trusted keys, so that the pairwise secret is not derivable from anything public.

If the identity key was substituted during that out-of-band exchange, this handshake completes
perfectly against the attacker. Nothing here can detect that, and nothing here claims to. The defence
is the spoken confirmation code (§6.5.8), and the honest limit is `DECISIONS.md` E1's: two people who
have never met have nobody to verify against.

**The pairing code carries a public identity key, and Handshake v1 requires nothing else from it.**
An implementation MUST NOT use an X25519 public key obtained from a pairing code as a party's
ephemeral in this exchange. A QR is displayed repeatedly and an invite blob sits in a mailbox and a
backup forever, so a key placed in either is reused across every attempt that reads it — precisely the
property §6.5.5 forbids an ephemeral from having.

> **This conflicts with two governing documents, and the conflict is recorded rather than resolved
> here.** `SPEC-ORIGINAL` §10 lists "key agreement public information" and a "pairing nonce" among the
> QR payload's contents, and `DECISIONS.md` E3 says the invite blob "carries public keys and a nonce".
> Both predate this section; both describe a pairing code that would supply the very values §6.5.5
> requires to be fresh per attempt. What actually shipped — `PairingCode` on both platforms — carries
> the identity key alone, which is the safe reading and the one this section is written against.
> `DECISIONS.md` takes precedence over this document, so reconciling the two is Don's call, not this
> section's: see `SPEC.md` §6. Nothing in Handshake v1 breaks if a code carries more, as long as no
> implementation treats what it carries as an ephemeral.

#### 6.5.2 Roles

One party sends first. Which one is a **transport and interface choice, not a cryptographic one**:

- `Initiator` — sends `PAIR_OFFER`, later sends `PAIR_CONFIRM`.
- `Responder` — waits for `PAIR_OFFER`, sends `PAIR_ACCEPT`.

The role is **not** derived from §4.2's `idPubLow`/`idPubHigh` ordering, even though that ordering is
already available to both sides and would settle it with no negotiation. It is not derived from it
because the ordering says who is `idPubLow` and nothing about who is holding the phone that scanned
the code, or which end of a remote invite is waiting on the other. Binding the first flight to key
order would mean a user who initiates in the real world must sometimes wait for the other person to
speak first, for a reason no interface could honestly explain.

Nothing cryptographic rests on the choice: the transcript both parties sign is canonical by identity
key order regardless (§6.5.4). Two devices that both believe they are the initiator simply fail to
progress — each is waiting for a `PAIR_ACCEPT` neither will send — and time out. They do not agree on
anything, and in particular they never agree on anything weaker.

#### 6.5.3 Message formats

Three frames, packet types 11–13, in §6's frame format: `packetType` (uint8), `flags` (uint8, MUST be
0, MUST be ignored on receipt), then a canonical-CBOR body per §1.

| # | Type | From | Body | Frame size | Bound |
|--:|------|------|------|-----------:|------:|
| 11 | `PAIR_OFFER` | initiator | `{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32)}` | 58–62 B | 96 B |
| 12 | `PAIR_ACCEPT` | responder | `{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32), 4: signature (64 B)}` | 125–129 B | 160 B |
| 13 | `PAIR_CONFIRM` | initiator | `{1: signature (64 B)}` | 70 B | 96 B |

Every field is required and every width is exact (§0: fixed-width means fixed-width). A decoder MUST
reject an unknown key, a missing key, a repeated key, a wrong field count, a wrong byte-string length,
and a `generation` above `uint32`. The size range in the table is the `generation` field's
shortest-form CBOR encoding varying between one and five bytes; `PAIR_CONFIRM`, which carries no
`generation`, is a fixed 70 bytes. The bound is what a receiver enforces before parsing, per §6, and
is generous by design: nothing legitimate comes near it, so a frame that does is rejected before its
body is read.

> **Measured, not assumed.** The frame sizes above are what `mesh-core-rs` actually emits, printed
> from `Packet::encode` for `generation` values spanning each of CBOR's uint widths — 58/59/60/62 for
> `PAIR_OFFER` and 125/126/127/129 for `PAIR_ACCEPT`. `61` and `128` are absent because CBOR has no
> four-byte uint; a range stated by arithmetic rather than by measurement would have included them.

**These three types are not part of §6.2's encounter sequence.** They share the frame space because
they travel over the same link and a receiver must demultiplex them from the same byte stream, but an
encounter never sends or expects one. A node that receives a pairing frame inside an encounter MUST
ignore it rather than treat it as an error — pairing genuinely can overlap an encounter, since two
phones in two pockets may be relaying while their owners are pairing.

**`PAIR_OFFER` carries no signature, deliberately.** There is nothing to bind yet: the peer's
ephemeral does not exist. An offer is therefore entirely forgeable, and an attacker who fabricates or
replays one gets a `PAIR_ACCEPT` it can never confirm, because confirming requires an Ed25519
signature under an identity key the responder already holds. It costs the responder one X25519
operation and one signature per forged offer, so an implementation SHOULD rate-limit inbound
`PAIR_OFFER`s and MUST only process them at all while the user has a pairing screen open — pairing is
a deliberate act, not something a node does in the background.

#### 6.5.4 The transcript

Both parties sign the same 182-byte string, differing in one byte:

```
transcript(role) =
    "mesh/handshake-v1"      17 bytes   domain-separation label
  ‖ idPubLow                 32 bytes   §4.2's canonical ordering of the two identity keys
  ‖ idPubHigh                32 bytes
  ‖ ephPubLow                32 bytes   the ephemeral of the party whose identity is idPubLow
  ‖ ephPubHigh               32 bytes
  ‖ nonceLow                 16 bytes   the nonce half contributed by that same party
  ‖ nonceHigh                16 bytes
  ‖ generation                4 bytes   uint32, big-endian
  ‖ signerRole                1 byte    0x00 if the signer's identity is idPubLow, else 0x01
                            ---------
                            182 bytes
```

`signature = Ed25519(identityPriv, transcript(signerRole))`. Verification MUST use *strict*
verification — rejecting small-order public keys and small-order signature components — so that a
signature is valid for exactly one key rather than several.

**Low and high are by identity-key ordering, never by role.** That is what lets both parties build
byte-identical transcripts without first agreeing who initiated, and it reuses the ordering §4.2
already defines rather than inventing a second one.

**Every field is fixed-width, so the concatenation is unambiguous.** No separator, no length prefix,
and none is needed: there is exactly one way to parse 182 bytes into these nine fields. A variable-width
field here would need length-prefixing to avoid the classic ambiguity where two different field
assignments produce the same signed string.

**The label carries no product name.** `tokens.rs` records what happened the last time one did: the
rename to Channel Messenger silently changed every derived key and every token, caught only because
the conformance vectors failed. `mesh/` ties the label to the protocol, which is the thing that
versions.

**`signerRole` is the reason neither signature can stand in for the other.** Without it both parties
would sign an identical byte string. The two are still verified under different keys, so a swap is
already useless whenever `idPubLow ≠ idPubHigh` — but that inequality holds only because §6.5.6's
first check enforces it, and one byte is a cheap price for not having the property depend on another
check being present.

**`generation` is inside the transcript.** A signature from one generation therefore does not verify
for the next, which is what stops an old recorded exchange being replayed into a re-pair.

#### 6.5.5 The exchange

```
Initiator                                                     Responder
  │                                                                │
  │  generate ephemeral keypair + 16-byte nonce half               │
  ├───── PAIR_OFFER {ephPub, nonce, generation} ──────────────────▶│
  │                                    generate ephemeral + nonce  │
  │                                    checks (§6.5.6), then sign  │
  │◀──── PAIR_ACCEPT {ephPub, nonce, generation, signature} ───────┤
  │  checks (§6.5.6), verify, then sign                            │
  ├───── PAIR_CONFIRM {signature} ────────────────────────────────▶│
  │                                                     verify     │
  │  paired                                                 paired │
```

Three flights, and `DECISIONS.md` E3 already predicted the count: *"Two round trips are unavoidable
(X25519 needs both public keys): invite → response → paired."*

The frames are ordinary byte strings. Nothing in this section requires a radio: the same three frames
can be carried by BLE, by Wi-Fi Aware, or by a user pasting them into two different messaging apps,
which is how `DECISIONS.md` E1's tier-2 remote pairing works. What the channel MUST provide is
ordering and integrity *within one attempt* — a frame delivered out of order is an abort (§6.5.6), not
something to be reassembled.

**Each side generates its ephemeral keypair fresh, per attempt, and MUST NOT reuse one.** Not across
peers, not across attempts with the same peer, and not on a retry after an abort. An implementation
MUST discard all handshake state on any abort and generate new material to try again; there is no
resumption, and offering one would hand an attacker unlimited attempts against a single ephemeral.

The ephemeral secret and the nonce half are the only entropy the exchange consumes: 32 bytes of X25519
ephemeral secret and 16 bytes of nonce per side, from the platform CSPRNG. If the CSPRNG read fails,
the pairing MUST fail. There is no fallback, because a predictable ephemeral is not a degraded pairing,
it is no pairing at all.

**The last flight is unacknowledged, and the asymmetry is real.** The initiator considers itself
paired the moment it emits `PAIR_CONFIRM`; the responder only when it has verified one. A lost
`PAIR_CONFIRM` therefore leaves the initiator holding a contact the responder does not have. There is
no fourth flight to close this, deliberately (§6.5.8), and the protocol cannot close it in general —
any acknowledgement has a last message of its own. What closes it in practice is the ceremony:
`DECISIONS.md` E1 has both people compare the confirmation code, and a contact that only one side
believes in is visible immediately, on the other person's screen, at the moment it matters. An
implementation SHOULD surface a pairing as incomplete until it has seen traffic from the peer, and
MUST make re-pairing possible without deleting anything (`generation` is negotiated for exactly this,
§6.5.7).

#### 6.5.6 Checks and abort conditions — normative

Every one of these is an **abort**. On abort an implementation MUST discard the handshake and all
material derived within it, MUST NOT send any indication of *why* to the peer (§0's oracle rule; the
distinctions below are for local diagnostics only), and MUST NOT accept any further frame on the same
handshake. Retrying means starting over with fresh entropy.

**Discarding means scrubbing, at the moment of the abort.** The ephemeral private key an aborted
handshake generated is live key material for an exchange that will never complete, and an
implementation MUST zeroize it when it aborts rather than leaving it to be scrubbed whenever the
handshake object is eventually released. A pairing screen can stay open, and a caller can reasonably
keep a failed attempt around to tell the user about it; neither is a reason to keep the key.

**An abort is a *pre-completion* event, and completion is terminal in the other direction.** Once a
party has completed — both signatures verified, the agreement contributory — no later frame may take
that back. A completed handshake MUST refuse every further frame, and MUST do so **without** discarding
what it derived: it does not process the frame, does not reply to it, and does not change state. This
is the one place where "discard the handshake" would be exactly wrong. Duplicate delivery is ordinary
on BLE, a `PAIR_CONFIRM` is replayable by anyone who heard it, and a link carries unrelated traffic;
treating any of those as an abort would let an unauthenticated party destroy a pairing that had
already succeeded, which is a worse outcome than every attack this section defends against. Both end
states are sticky: a failed handshake stays failed, a completed one stays complete, and neither
re-enters the state machine.

| # | Condition — abort if | Applies to |
|--:|----------------------|------------|
| 1 | The peer's identity key equals our own | both, before the exchange begins |
| 2 | The frame does not decode, or violates §6.5.3's widths and field set | every received frame |
| 3 | The frame type is not the one this stage expects | every received frame |
| 4 | The peer's `ephemeralPublic` equals our own | `PAIR_OFFER`, `PAIR_ACCEPT` |
| 5 | The peer's `nonce` equals our own | `PAIR_OFFER`, `PAIR_ACCEPT` |
| 6 | `PAIR_ACCEPT.generation` is below the generation we offered | `PAIR_ACCEPT` |
| 7 | The peer's signature does not verify under the identity key from the pairing code | `PAIR_ACCEPT`, `PAIR_CONFIRM` |
| 8 | `X25519(myEphemeralPriv, theirEphemeralPub)` is non-contributory (all-zero) | whichever frame carried the peer's ephemeral |
| 9 | The peer's `generation` exceeds our own stored view by more than `MAX_GENERATION_ADVANCE` (1024) | `PAIR_OFFER`, `PAIR_ACCEPT` |

**The order matters, and it is not the same on both sides.** Each frame is processed exactly as
follows, stopping at the first failure:

- **Responder, on `PAIR_OFFER`** — 2, 3, 4, 5, 9, then `generation = max(offered, own)`, then sign,
  then the agreement and 8. There is no signature to verify on this flight; the responder is committing
  an X25519 operation and a signature to an unauthenticated peer, which §6.5.3 accounts for.
- **Initiator, on `PAIR_ACCEPT`** — 2, 3, 4, 5, 6, 9, 7, then sign, then the agreement and 8.
- **Responder, on `PAIR_CONFIRM`** — 2, 3, 7. The agreement already happened on the offer, so 8 has
  already run; this flight adds nothing but the initiator's signature over the same transcript.

9 runs before 7 on the initiator's side for the same reason 4 and 5 do: it is an arithmetic comparison
that does not depend on an attacker being unable to sign, so there is no benefit to paying for a
signature verification first.

Check 7 therefore runs *before* the agreement on the initiator's side and *after* it on the
responder's, which is a consequence of who signs first and not a difference in strength: neither side
completes, and neither side keeps any derived material, unless both signatures verified and the
agreement was contributory.

**1 — identity misbinding, degenerate case.** If the peer presents our own identity key, the
`idPubLow`/`idPubHigh` ordering collapses, both signatures in the exchange verify under a single key,
and `signerRole` is the only thing left distinguishing them. Refusing outright is simpler than
reasoning about that, and it is never a legitimate pairing: pairing a user's own two devices pairs two
*separate* identities (`DECISIONS.md` J20), never one identity with itself.

**3 — no resynchronisation.** This is the deliberate opposite of §6.2, where an out-of-sequence packet
is ignored because encounters are resumable and repeats are normal. A pairing has exactly one legal
successor at every point.

**3 — the denial of service it accepts, stated rather than glossed.** Because `PAIR_OFFER` is
unauthenticated (§6.5.3) and because there is exactly one legal successor, a forged or replayed
`PAIR_OFFER` that reaches a waiting responder *first* moves it to awaiting `PAIR_CONFIRM`; the
legitimate offer then arrives out of sequence and aborts the attempt. **This is a known and accepted
denial of service.** It is not closed here, and closing it would mean authenticating a flight that has
nothing to authenticate against yet — the peer's ephemeral does not exist until this frame delivers it.
What makes it tolerable is the ceremony: pairing happens with both people present and a screen open
(§6.5.3 requires exactly that), so the attack costs one retry and is visible as one. Sustaining it
costs the attacker unbounded repetition, which is why §6.5.3 also says an implementation SHOULD
rate-limit inbound offers.

What an implementation MUST do is make it **diagnosable**. This abort and a signature failure (7) lead
a user to opposite conclusions — "someone interfered, start again" versus "the key in that pairing code
is not who you think it is" — so the two MUST be distinguishable in local diagnostics, and a second
`PAIR_OFFER` on one attempt SHOULD be reported distinctly from a generic out-of-sequence frame. None of
this may be signalled to the peer (§0's oracle rule); it is for the user standing in front of the
device.

**4 and 5 — reflection.** These are stated honestly, because it is easy to overclaim them.

Echoing our own ephemeral back does **not**, on its own, hand an attacker the secret: we would compute
`X25519(a, aG) = a²G`, and recovering that from `aG` is exactly the square Diffie–Hellman problem,
which is not easy. What it does do is put us in a state no honest peer ever produces — the probability
of an honest peer independently generating our ephemeral is negligible — and derive keys from a value
with an unusual algebraic relationship to our own key, about which this protocol wishes to reason not
at all. The same applies to the nonce half: a reflected `nonceLow` makes `pairingNonce = nonceLow ‖
nonceLow`, which is still fresh so long as we are honest, and still a shape nothing legitimate emits.

What checks 4 and 5 are genuinely *for* is closing the reflection family alongside check 1. Check 1
stops an attacker collapsing the two roles through the identity keys; 4 and 5 stop it being done
through the ephemeral or the nonce instead. Together they mean an implementation never has to answer
"what happens if both halves of this exchange are the same value" — the answer is that it aborts. They
are also the cheapest checks available, and unlike check 7 they do not rest on the attacker being
unable to produce a signature, which is why they run first.

**6 — generation rollback**, and **9 — generation ceiling.** The counter has to be defended at both
ends: 6 stops it being dragged backwards onto tokens that may already have been observed, 9 stops it
being thrown forwards to a value it can never advance past. See §6.5.7 for both.

**8 — small-order points.** A peer that sends a low-order point forces an all-zero agreement that it
knows in advance. This is *not* subsumed by check 7: the peer whose signature verifies is exactly the
peer who would do it deliberately, so a valid signature is no evidence against it. Implementations
MUST test the agreement for contributory behaviour rather than assuming their X25519 library rejects
such inputs — the widely used ones return the value rather than erroring.

#### 6.5.7 Outputs — `sharedSecret`, `pairingNonce`, `generation`

```
sharedSecret = X25519(myEphemeralPriv, theirEphemeralPub)          32 bytes
pairingNonce = nonceLow ‖ nonceHigh                                32 bytes
generation   = the value carried in PAIR_ACCEPT                    uint32
```

`sharedSecret` is §4.2's, exactly. `pairingNonce` then feeds §4.2's
`pairingSalt = SHA-256(idPubLow ‖ idPubHigh ‖ pairingNonce)`, and from there §4.3's `routingSecret`
and §4.5's `messageKey` follow unchanged. **Nothing downstream of §4.2 changes.**

**`pairingNonce` is a contribution from both sides, concatenated — never XORed, and never one party's
choice.** Each side supplies 16 bytes; they are placed in §4.2's canonical identity-key order, so both
sides compute the same 32 bytes without agreeing a role.

Concatenation rather than XOR is the load-bearing detail. Whoever sends second sees the other's half
before choosing its own. Under XOR that second party could pick its half so the *result* equals any
value it liked — including one from an earlier pairing, forcing `pairingSalt` back to a previous
value. Under concatenation it cannot: the first party's 16 bytes appear in the output verbatim.
**The property this buys, stated exactly: `pairingNonce` is fresh whenever *either* party's CSPRNG is
sound, regardless of the other's.**

Note what that is and is not worth. `pairingSalt` reuse alone does not repeat any key, because
`routingSecret` and `messageKey` take `sharedSecret` as their HKDF input and the ephemerals make that
fresh (§4.3, §4.5). `pairingNonce` is a backstop against the case where `sharedSecret` is *not* fresh
— a device whose ephemeral generation is broken, stuck or repeating, a failure an implementation can
have without knowing. The XOR variant would let a malicious peer defeat precisely that backstop, which
is the only thing the nonce was there for. So: belt-and-braces, and the belt has to be one the other
party cannot unbuckle.

**`generation` (§4.2, `DECISIONS.md` C5) is negotiated, monotonically.** Each side stores a generation
per contact: `0` at first pairing, stored + 1 on a re-pair. The initiator proposes its value in
`PAIR_OFFER`. The responder MUST answer with `max(offered, itsOwn)` in `PAIR_ACCEPT`, and the
initiator MUST abort if that value is *below* what it offered (check 6). Both then use the
`PAIR_ACCEPT` value, which is what the transcript covers.

The negotiation exists because the two sides' counters can legitimately diverge: a user who deletes a
contact and re-adds it has lost the count while the peer has not. Rejecting the mismatch would make
re-pairing impossible after any deletion. Taking the maximum converges, and refusing a decrease keeps
it monotone, so an attacker replaying an old exchange cannot roll two parties back onto a generation
whose tokens may already have been observed.

**A caller MUST persist the negotiated `generation`, not the one it proposed.** Deriving tokens under
a generation the peer is not using produces a contact that looks entirely healthy and receives
nothing, forever — an invisible failure, not an error.

**`PAIR_OFFER.generation` is not covered by any signature, and that is safe *for the value itself*.**
It is an unauthenticated hint in the same sense as §6.3's `ROUTE_RESPONSE` quality fields. An attacker
who lowers it changes nothing, because the responder takes the maximum. An attacker who raises it can
only push both parties onto a *higher* generation that they will then agree on, and everything derived
is freshly keyed regardless. The value that is authenticated — the one in `PAIR_ACCEPT`, inside the
transcript — is the one both sides actually use.

**That argument is correct and it does not go far enough: "higher" runs out.** `generation` is a
`uint32`. An attacker who forges one `PAIR_OFFER` carrying `u32::MAX` makes `u32::MAX` the negotiated
value, and because re-pairing requires the counter to *increase*, that pair of identities can never
re-pair again — not with a new ceremony, not with a new QR, not ever. One forged frame from anyone
within range of one pairing, and the relationship is permanently unrecoverable. That is a strictly
worse outcome than any of the ones the paragraph above rightly dismisses, and it is why the advance is
bounded.

**Normative — the advance is bounded.** A party MUST abort if the peer's `generation` exceeds its own
stored view by more than `MAX_GENERATION_ADVANCE`, which is **1024** (§6.5.6, check 9). This applies to
`PAIR_OFFER.generation` on the responder's side and to `PAIR_ACCEPT.generation` on the initiator's; the
accept is signed and so only the peer named by the pairing code can set it, but stating the rule as
"no single negotiation advances my counter by more than the bound" is cheaper to reason about than one
that holds on one flight and not the other. The bound is computed with saturating arithmetic, so a
party legitimately near the ceiling can still negotiate up to it rather than having the comparison wrap
and admit exactly the value it excludes.

**Why a *relative* bound.** An absolute ceiling — reject anything above some `N` — buys nothing: the
attacker picks `N - 1` and the counter is pinned just below it instead. Bounding the advance preserves
the property that actually matters: after any negotiation this side's counter is at most
`own + 1024`, and since honest re-pairing raises `own` by exactly one at a time, no sequence of forged
offers can walk the counter towards the ceiling faster than the two users could themselves.

**Why 1024, and what it costs.** The divergence this negotiation exists to absorb is one party having
deleted a contact and lost the count while the peer kept it; its size is the number of times those two
people have genuinely re-paired, which is a handful over a device's life. 1024 is three orders of
magnitude clear of that, and leaves the counter some four million forced advances short of `u32::MAX`.
The cost is real and remote: two peers whose counters have legitimately diverged by more than 1024
can no longer re-pair until one of them resets. That is a recoverable local failure traded against an
unrecoverable remote one.

#### 6.5.8 What Handshake v1 provides, and what it does not

**Provides:**

- **Mutual authentication against the pairing code.** Neither party completes unless the other proved
  possession of the private half of the identity key that came out of the QR, NFC tap or blob. A
  man-in-the-middle who did not substitute that key cannot complete the exchange in either direction.
- **A pairwise secret not derivable from any public value.** This is the property the placeholder
  handshake lacked entirely.
- **Freshness per attempt.** Two pairings between the same two identities share no key material:
  distinct ephemerals give a distinct `sharedSecret`, and distinct nonce halves give a distinct
  `pairingSalt` (§6.5.7). This is a *sanity* property of the exchange, not a forward-secrecy claim for
  the messaging that follows — see below.
- **Replay resistance.** A recorded `PAIR_ACCEPT` or `PAIR_CONFIRM` cannot be replayed into any later
  exchange: the transcript covers both ephemerals, both nonce halves and the generation, so a replayed
  signature is a signature over the wrong string. A recorded `PAIR_OFFER` can be replayed, and doing so
  costs the victim one X25519 operation and one signature before the attacker fails at
  `PAIR_CONFIRM` — an initiator MUST accept at most one `PAIR_ACCEPT` per offer it sent, and a
  responder at most one `PAIR_CONFIRM` per accept it sent, which §6.5.6's single-shot state machine
  already requires. **"At most one" means the second is refused, not that it undoes the first**: a
  replayed final flight against a party that has already completed is discarded and the completed
  pairing is untouched (§6.5.6). This is `DECISIONS.md` E3's "single-use" property, discharged by the
  handshake rather than by the blob.
- **Reflection refusal**, by checks 1, 4 and 5 — see §6.5.6 for exactly what that does and does not
  buy, since it is the claim in this list most often overstated.

**Does not provide:**

- **Forward secrecy for messages.** The ephemerals are discarded, but `messageKey` (§4.5) is static
  for the life of a generation, so compromising a device discloses its stored conversations. §4.5 says
  this already and a Double Ratchet replaces that derivation wholesale in a later CryptoSuite. Handshake
  v1 does not change the claim in either direction, and v1 MUST NOT be described as forward-secret.
- **Protection against a substituted identity key.** §6.5.1. The spoken confirmation code is the
  defence:
  `confirmationCode = SHA-256(idPubLow ‖ idPubHigh)[0..3]`, read as a big-endian 24-bit integer,
  modulo 1 000 000, rendered as six digits in two groups of three (`SPEC-ORIGINAL` §10,
  `DECISIONS.md` J15). It is computed over the **two identity keys only**, and deliberately not over
  the handshake transcript. Covering the transcript would look stronger and buy nothing: every other
  value in the transcript is already signed under those two keys, so an attacker who cannot substitute
  an identity key cannot alter any of them either. The one thing no signature in this protocol can
  catch is exactly the one thing the spoken code is for.
- **Identity privacy against a passive observer of the pairing itself.** An observer who *already
  holds* a candidate identity public key can reconstruct the transcript from the wire — both
  ephemerals, both nonce halves and the generation are all in the clear — and verify the signature
  against that candidate, learning that this party is here and pairing. This is a confirmation oracle,
  not a disclosure: it tells an observer nothing it could not have learned by holding the key and
  watching for the party's presence tokens (§4.6), and it requires physical presence during the
  pairing. **It is nevertheless a real disclosure and is recorded here rather than glossed.** The
  standard remedy — SIGMA's: derive a key from the agreement and encrypt each signature under it, so
  only the intended peer can verify — is a wire-format change and therefore a Handshake v2 decision,
  not a v1 one.
- **Post-quantum protection.** X25519 and Ed25519 are both classically secure only. A recorded pairing
  plus a future quantum adversary recovers `sharedSecret` and everything under it. CryptoSuite v1 is
  replaced wholesale, never extended (§4.1).
- **Availability of any single pairing attempt.** The first flight is unauthenticated by design, so a
  forged or replayed `PAIR_OFFER` delivered ahead of the real one consumes a responder's attempt and
  the honest offer then aborts (§6.5.6, check 3). Nothing agreed is weakened — the attacker gets a
  `PAIR_ACCEPT` it can never confirm — and the users retry, which in a face-to-face ceremony they can.
  It is an accepted denial of service, bounded by rate-limiting and by pairing only ever running with
  a screen open, and it MUST be reported distinctly enough that a user is told to start again rather
  than told their peer's key is wrong.
- **A bound on the generation counter across many ceremonies.** Check 9 stops a single frame reaching
  the ceiling; it does not stop an on-path attacker who rewrites the unsigned `PAIR_OFFER.generation`
  during a ceremony that then genuinely completes, advancing that pair's counter by 1024 instead of 1.
  Repeating that to exhaust a `uint32` means being on-path for some four million completed pairings
  between the same two people, which is not a threat this protocol needs to answer. It is recorded
  rather than defended against.
- **Explicit key confirmation.** There is no fourth flight proving the initiator derived the same keys.
  A derivation mismatch surfaces at the first message instead. A fourth flight would buy detection a few
  seconds earlier at the cost of another round trip inside a window §6.2 warns may be ten seconds long.

---

## 7. Route discovery — RoutingProtocol v1

Bounded discovery first, courier mode as fallback (`SPEC-REVISION` §1.4). Flooding is not the
primary mechanism.

### 7.1 Ancestry

This is **AODV-derived**: a query ID, a hop budget, duplicate suppression, short-lived reverse-path
state, and a response returned along that reverse path. Substituting opaque rotating tokens for
addresses is the privacy change; the mechanics are otherwise the classic algorithm.

Naming the ancestry is deliberate (`DECISIONS.md` D4, from Don's *"quite a bit of routing literature
we can borrow from rather than inventing every mechanism ourselves"*). It brings known failure modes
with it — route reply storms, the local-repair versus full-rediscovery trade, and stale-route loops —
which is cheaper than rediscovering them in the field.

**Deliberate deviations from AODV:**

| AODV | Here | Why |
|---|---|---|
| Destination sequence numbers prevent stale routes | Short route lifetimes (15 s) instead | Sequence numbers are a stable per-destination identifier — precisely the linkable value this protocol exists to avoid |
| Nodes keep persistent routing tables | Reverse-path state only, 10 s | Persistent tables are a topology map (`SPEC-REVISION` §1.5) |
| Route replies may be sent by intermediate nodes with a fresh-enough route | Only the destination may originate a reply | An intermediate node cannot compute the MAC, so it cannot prove the destination exists |
| Local repair patches a broken link | Full rediscovery | Local repair requires knowing the path; §7.6 |

### 7.2 Originator state machine

```
                    message queued, no held route
        ┌──────┐ ─────────────────────────────────▶ ┌─────────────┐
        │ IDLE │                                    │ DISCOVERING │
        └──────┘ ◀───────────────────────────────── └─────────────┘
            ▲          route expired / next hop gone    │        │
            │                                           │        │ rings exhausted
            │            valid ROUTE_RESPONSE           │        ▼
            │                                           │   ┌─────────┐
            │        ┌────────────┐                     │   │ COURIER │
            └─────── │ ROUTE_HELD │ ◀───────────────────┘   └─────────┘
                     └────────────┘                              │
                            │                                    │ encounter offers
                            └────── send fails ──────────────────┘ a better path
```

**IDLE → DISCOVERING.** A message is queued and no unexpired route is held for its destination.

**DISCOVERING** performs an **expanding ring search** (`DECISIONS.md` D2): budget 1, then 2, then 3,
up to `MAX_DISCOVERY_HOPS`. Nearby destinations resolve without touching the wider cluster.

> **Each ring MUST use a fresh `queryID`.** Reusing one is the classic implementation bug here:
> relays that suppressed the narrow query would suppress the wider one identically, and the search
> would never actually expand. The originator MUST retain the set of `queryID`s belonging to one
> logical search so it can attribute a late response to the right destination.

Ring timeout SHOULD scale with the budget — `RING_TIMEOUT(r) = RING_BASE × 2 × r` **[policy]**, so a
one-hop ring is not abandoned before a three-hop round trip could possibly complete.

**DISCOVERING → ROUTE_HELD** on a `ROUTE_RESPONSE` whose MAC verifies under the destination's
`routingSecret`. An unverifiable response is discarded silently and does not end the search.

**ROUTE_HELD** retains `nextHop`, `hopCount` and computed cost for `ROUTE_STATE_TTL` **[policy]**,
default 15 s. It holds **no path**.

**DISCOVERING → COURIER** when every ring has been tried without a verified response. This is the
normal case at low network density, not an error (`DECISIONS.md` E2), and it MUST be the cheapest,
best-tested branch.

### 7.3 Relay behaviour

On `ROUTE_QUERY`:

1. If `queryID` is in the seen-set → **drop silently**. Do not rebroadcast, do not answer.
2. If `hopBudget == 0` → drop.
3. If `expiresAt` has passed → drop.
4. Record reverse-path state `queryID → {receivedFrom, hop, linkQuality, expires = now + QUERY_STATE_TTL}`.
5. Add `queryID` to the seen-set for `SEEN_QUERY_TTL` **[policy]**.
6. If the query's `presenceToken` matches a cached `PRESENCE` entry, forward **only** to that peer.
   Otherwise rebroadcast with `hopBudget − 1`.

Step 6 is what the presence cache buys: a query converges toward a known holder rather than flooding
(`SPEC-REVISION` §1.6). A cache miss degrades to the flood, never to a failure.

> **Duplicate suppression, not the hop budget, is what bounds flood cost.** With the seen-set, total
> transmissions for one query are bounded by the number of nodes in the cluster — not by
> branching-factor raised to the hop count. The hop budget bounds *scope and latency*. An
> implementation that omits or short-changes the seen-set turns a bounded flood into an exponential
> one, so this is not an optimisation.

On `ROUTE_RESPONSE`: look up `queryID`. If no reverse-path entry exists (expired, or never seen the
query) → **drop**. Otherwise update the metrics per §8.4 and forward to `receivedFrom`. A relay MAY
then cache `presenceToken → {peer, cost, expires = now + ROUTE_STATE_TTL}`.

**Nodes MUST NOT build persistent tables of the form "I can reach X."** Reverse-path and route state
are short-lived and keyed by opaque values (`SPEC-REVISION` §1.5).

### 7.4 Destination behaviour

On `ROUTE_QUERY` whose `presenceToken` matches one of its own tokens for the previous, current or
next epoch (§4.6): emit exactly one `ROUTE_RESPONSE` per `queryID`, initialised per §8.4, MAC'd per
§6.3.

A destination MUST suppress further responses for a `queryID` it has already answered — otherwise a
flooded query arriving by several paths produces a reply storm.

A destination MUST NOT respond to a query whose token it does not recognise. Silence is the only
correct answer; anything else is an oracle.

### 7.5 Timers

| Timer | Default **[policy]** | Purpose |
|---|---|---|
| `QUERY_STATE_TTL` | 10 s | Reverse-path entry lifetime (Don's sketch) |
| `ROUTE_STATE_TTL` | 15 s | Held-route lifetime (Don's sketch) |
| `SEEN_QUERY_TTL` | 60 s | Duplicate suppression window |
| `RING_BASE` | 700 ms | Expanding-ring timeout base |
| `PRESENCE_CACHE_TTL` | 900 s | Cached presence entry lifetime |
| `ADVERT_ROTATE` | 900 s | Ephemeral advertisement ID rotation |

Route state is deliberately shorter than presence state: knowing *someone is around* stays useful far
longer than a specific path stays valid.

### 7.6 Route breakage

`SPEC-REVISION` §5 step 6 requires proving that routing **invalidates or expires rather than retrying
a stale path forever.** Normative:

- On transport failure to `nextHop`, the sender MUST **immediately invalidate** the held route. It
  MUST NOT retry the same next hop for the same envelope.
- It MAY perform **one** fresh discovery. If that fails, the envelope enters courier mode.
- A relay holding an envelope whose next hop has gone MUST NOT return it to the previous hop —
  reverse delivery would create loops. It becomes a courier for that envelope until expiry.
- Held routes expire on their timer regardless of use. There is no refresh-on-use, because refreshing
  would let a path that has silently broken persist indefinitely.

**No local repair.** Patching a broken link mid-path requires knowing the path, and the originator
deliberately does not (`DECISIONS.md` D3). Full rediscovery is the cost of that privacy property, and
it is affordable because duplicate suppression makes rediscovery cheap.

### 7.7 Courier mode

An envelope with no route is queued and moves opportunistically by `INVENTORY` / `WANT` on each
encounter (§6.2). It is subject to relay retention, not sender retention (`DECISIONS.md` A4) on any
node other than its originator.

Courier delivery does **not** use route discovery, presence tokens, or reverse-path state. It
proceeds purely by a peer recognising its own `destinationToken` in an offered inventory — which is
why the two token types must be derived separately (§4.4). A courier carrying an envelope for Carol
cannot tell that the person it just walked past was Carol.

**Both modes coexist.** Live mesh delivers now; courier delivers later. A node MAY hold an envelope in
courier mode while discovery is still in flight, and MUST suppress the duplicate on `packetID` if
both succeed.

### 7.8 Persistent nodes and wake beacons

A persistent node (`DECISIONS.md` A3) is a user-operated desktop machine acting as relay, household
mailbox and client. Beyond storage and availability it has one capability a phone does not: it can
advertise continuously.

**Beacon role.** A persistent node SHOULD advertise the ChannelMessenger service UUID continuously, at
`BEACON_ADVERT_INTERVAL` **[policy]** (default 1000 ms), and set `roleFlags` bit 2.

This matters because of an OS constraint rather than a protocol one. A suspended or terminated mobile
app can be woken by the platform when a peripheral advertising a known service UUID appears
(§7.9). A stationary always-on advertiser therefore acts as a **wake trigger** for every phone in the
household: arriving home brings a device into range, the platform wakes the app, and queued messages
deliver — with no server, no push, and no internet.

Range is the radio's, so this covers a room or two rather than a building. Deploying several nodes is
a legitimate way to extend it.

**A beacon MUST NOT behave differently from any other node on the wire.** It rotates its ephemeral
advertisement ID on the same schedule (§4.8), emits the same packet types, and discloses nothing
additional. The role bit affects route-cost preference and nothing else.

**Keyless by construction.** A persistent node performs the mailbox and relay roles without holding
identity keys — it stores ciphertext, relays, and answers inventory queries, none of which require
decryption. Only the client role needs keys. An implementation SHOULD run the always-on component as
a separate process that never receives key material, so compromising the component that is exposed
all day yields envelopes nobody can read.

**Privacy note, stated rather than glossed.** A stationary node is a fixed installation, and ID
rotation does not conceal a device that is *always present in one place* from an observer who is also
in one place. Anyone monitoring a location learns that a node lives there. This is inherent to running
fixed infrastructure and is recorded in `THREAT-WALKTHROUGH.md` §8 rather than claimed away. It is one
reason the persistent node is optional.

### 7.9 Platform behaviour — researched 2026-09-01

Previously a list of assumptions marked *believed, not measured*. Researched
against live sources on 2026-09-01 and verified against primary documentation;
each item below says how strong the evidence is. **Still not measured on our own
hardware** — that remains the probe's job.

#### MEASURED 2026-09-01 on real hardware: Android does NOT see our backgrounded app

Tested with an iPhone 17 Pro Max (iOS 26) advertising, and a Samsung Galaxy A16 5G
(Android 16) scanning via nRF Connect.

| Condition | Result |
|---|---|
| Probe in **foreground**, Android filtering by our 128-bit service UUID | **FOUND.** Exactly one device, −38 dBm, 30 ms interval |
| Probe **backgrounded**, same UUID filter | **NOT FOUND.** The UUID leaves the standard fields, as documented |
| Probe **backgrounded**, Android filtering Apple overflow `4C 00 01` | Device found — **but the bitmask does not contain our service** |

**The overflow area is real and present.** The raw advertisement carries
`0x01` followed by a 128-bit mask, exactly as the reverse-engineering
literature describes:

```
0100000000080000000000000000000004
```

**But that mask is byte-for-byte identical whether our app is running
backgrounded, or killed outright.** Those two set bits belong to other apps on
the phone. Repeated with a second, unrelated service UUID to rule out a hash
collision with an already-set bit: still identical.

**So `SPEC-REVISION` §5's four-device acceptance test has a real hole in it.**
Cross-platform discovery works while the iOS app is on screen and stops when it
is not.

#### MEASURED: a backgrounded iPhone CAN still discover others

The single most important result of the session, and it rescues courier mode.

```
2026-09-02T00:02:28Z  [bg]  discovered 6E83C072 rssi -45 → LQ 196
```

That is the iPhone probe, **backgrounded**, logging a discovery of the iPad. iOS
woke the app for the event and it recorded the sighting with its own
app-state tag.

**Being findable and being able to find are separate capabilities**, and only
the first is broken. A backgrounded iPhone cannot be discovered by Android — but
it can still scan, wake on a match, and connect outward.

**What this means for the design.** An iPhone in a pocket remains a useful mesh
participant: it can walk up to an advertising Android or desktop node and
exchange messages. It simply must always be the party that *initiates*. Courier
mode survives; what dies is the case where a stranger's device discovers a
sleeping iPhone.

The mesh therefore needs **advertisers that are always discoverable** — Android
foreground services and desktop nodes — with iPhones as mobile clients that find
them. That is a real architectural constraint, not a limitation to apologise
for, and it makes the free desktop node (`DECISIONS.md` F10) considerably more
important than it looked.

*Evidence: measured directly. The reciprocal test (iPad discovering the
backgrounded iPhone) is inconclusive — see the probe defect below.*

#### CONCLUSIONS: what the radio actually permits

Four things are measured, one is inferred, and one is still open. Keeping those
categories apart matters, because three conclusions were withdrawn tonight after
a control contradicted them.

| | Result | Basis |
|---|---|---|
| Foreground iPhone → Android | **works** | measured |
| Backgrounded iPhone → discovered by Android | **fails** | measured, two UUIDs |
| Backgrounded iPhone → discovers others | **works** | measured |
| Android advertising persistently | **works** | measured (foreground service) |
| Backgrounded iPhone → discovered by iOS | **untested** | Apple documents it; our test was invalid |

**The governing asymmetry: being findable and being able to find are separate
capabilities, and iOS only breaks the first.** Every design consequence below
follows from that one sentence.

#### The Android problem, and whether it can be fixed

When our app is backgrounded, iOS removes the 128-bit service UUID from the
standard advertising fields. Android's `ScanFilter` reads only those fields, so
a filtered Android scan sees nothing at all.

The published workaround — scan unfiltered, find Apple manufacturer data
`FF 4C 00 01`, test the bit for your UUID in the 128-bit "overflow" bitmask — is
widely cited and appears in Google's own CompanionDeviceSupport. **It did not
reproduce here.** The bitmask was byte-identical whether our app was
backgrounded or force-killed, with two different service UUIDs. If our UUID were
setting a bit, those masks would differ.

*Honest limit on that finding:* one device, one iOS version. And the result is
equally consistent with a different cause — that the backgrounded peripheral
stopped advertising altogether. We have never confirmed the backgrounded iPhone
is advertising to **anything**. Distinguishing the two is exactly what the
iOS-to-iOS test settles, and that test is now runnable.

**The fix is architectural, and both halves are already measured working.**

Invert the roles. The iPhone is always the scanner and always initiates; Android
and desktop nodes always advertise. The pairing that fails — Android hunting for
a sleeping iPhone — then never has to happen. This relies on no undocumented
behaviour and no reverse-engineered bitmask.

**What this leaves exposed: two backgrounded iPhones with nothing else in
range.** Neither can find the other unless Apple's iOS-to-iOS overflow discovery
works. That is the open test, and it decides whether a cabin full of iPhones
with no Android and no desktop is a working mesh or a dead one.

**Fallback, if the bitmask really is gone.** Android scans unfiltered, connects
to each Apple device it sees, runs GATT discovery, disconnects when our service
is absent. It works in principle. It is also dozens of connections in a crowded
room, it punishes battery on both sides, and iOS MAC randomisation defeats any
attempt to cache the negatives. A last resort, not a plan.

**Ruled out:** changing advertised services while already backgrounded — iOS 14+
forbids it. Our setup happens in the foreground, so this costs us nothing.

**Worth investigating separately:** Wi-Fi Aware (iOS 26+) implements the Wi-Fi
Alliance NAN standard rather than proprietary AWDL, so it should interoperate
with Android's Wi-Fi Aware. It needs an entitlement and iPhone 12+. *Not
verified by us — from secondary research only.* It is a possible second radio,
not a repair to BLE.

#### The design consequence

The mesh needs **always-discoverable advertisers**, and iPhones cannot be them.
Android foreground services and desktop nodes can.

*This paragraph originally concluded that the desktop node was therefore the
structural rendezvous point of the network. Don corrected it: that is true in a
house and worthless on a trail, which is the scenario the product exists for.
The conclusion was drawn from one measurement and over-reached. See
`DECISIONS.md` J1–J3 for the corrected framing — direct phone-to-phone is the
product, and multi-hop relay is unproven until measured.*

**The case that actually matters is better than this section first suggested.**
The sender is, by definition, in the foreground — they are typing. So the
question is never "can two sleeping phones find each other," it is "can a
sleeping phone find a phone someone is actively using." That is measured
working, iOS to iOS: the iPad returned to the foreground and began advertising
at `00:02:28`, and the backgrounded iPhone logged the discovery in the same
second.

#### KNOWN PROBE DEFECT — scanning is never restarted

`BLEProbe` calls `scanForPeripherals` only from `centralManagerDidUpdateState`,
which fires once when Bluetooth powers on. If iOS stops the scan — as it appears
to on backgrounding — nothing restarts it, and the app is silently deaf
thereafter.

This invalidates any *negative* discovery result from a device that has been
backgrounded and returned, which includes the iPad's failure to see the iPhone.
It does not affect positive results, and it does not affect the advertising-side
findings, which were measured from Android.

**Fix before further discovery testing:** restart scanning on
`willEnterForeground`, and after any `willRestoreState`.

**Also measured: state restoration works.** After the app was killed outright,
iOS relaunched it on its own and handed back both managers — the probe logged
`RESTORED by iOS from terminated state`. TN3115's mechanism is real on this
hardware. (A user *force-quitting* from the app switcher is a different case and
still expected to be permanent; not tested.)

**Not established, and NOT to be read as negative results:**

| Question | Status |
|---|---|
| Can a **backgrounded iPhone still discover others**? | **Untested.** An attempt failed because the Android advertisement was misconfigured — the foreground control also saw nothing, which proves the target was wrong, not the platform |
| **iPhone → iPhone** backgrounded | **Untested.** This is the case Apple designed the overflow area for, and the one most likely to work. An iPad is the obvious second device |
| Android → iPhone, either direction | Untested |

Discovery has two directions and they are separate capabilities:
`bluetooth-peripheral` (being findable) and `bluetooth-central` (finding). Only
the first was measured. **If a backgrounded iPhone can still scan outward,
courier mode survives** — an iPhone in a pocket could still initiate contact
with an advertising Android or desktop node, and would simply always be the one
initiating. That single unknown is worth more than anything else outstanding.

**One thing this does NOT yet distinguish**, and it matters:

- iOS is advertising our service in the overflow area and Android cannot decode
  it, **or**
- our app stops advertising entirely when backgrounded — a defect in the probe,
  not a platform limit.

An Apple device scanning would separate the two, since `CoreBluetooth` exposes
`CBAdvertisementDataOverflowServiceUUIDsKey` to Apple platforms. A macOS
command-line attempt failed: modern macOS refuses CoreBluetooth to an unbundled
binary. **The next step is a small bundled Mac app, or the iPad running the
probe as a scanner.** Until that is done, treat the cause as unknown and the
effect as established.

**Design consequence, which holds either way.** Do not build on backgrounded
iOS→Android discovery. Android nodes running a foreground service, desktop
nodes, and iOS under an explicit Mesh Active session (`SPEC-ORIGINAL` §28) are
the load-bearing paths. A backgrounded iPhone is, on this evidence, not
discoverable by Android at all.

*Evidence: measured directly, twice, with two different service UUIDs. The
earlier claim in this section — that an unfiltered Android scan can detect our
backgrounded service — was drawn from research that verified against its
sources but does not survive contact with the hardware.*

#### Force-quit is permanent

State restoration relaunches a **system**-terminated app — jetsam, crash, memory
pressure — given `CBCentralManagerOptionRestoreIdentifierKey`, a
`willRestoreState:` implementation and the `bluetooth-central` background mode.

It does **not** survive a user swiping the app away in the app switcher. Apple's
TN3115 is explicit. iOS 26 adds a narrow exception for apps using
AccessorySetupKit, which does not apply to us.

**A user who "closes" the app removes themselves from the mesh until they open
it again.** The UI must say so plainly rather than letting people discover it.

*Evidence: strong — Apple technote TN3115, updated September 2025 for iOS 26.*

#### The wake window is about ten seconds, and small

Roughly ten seconds per Bluetooth event, extendable if the peer keeps traffic
flowing within it. Realistically a few hundred bytes to a few kilobytes at
background connection parameters.

**This confirms the design.** A 256-byte padded message transfers comfortably; a
32 KB envelope will not complete in a background window. Bulk transfer is not
available and text-only is not a temporary limitation.

*Evidence: moderate — Apple's long-standing ~10s figure plus developer reports,
not a precise published guarantee. The probe should measure it.*

#### Local Push Connectivity is unavailable to us

`NEAppPushProvider` requires the `app-push-provider` entitlement, which Apple
grants case by case — historically for local-network VoIP where APNs is
unreachable. It does *not* require MDM. Assume we will not be granted it.

*Evidence: strong — Apple technote TN3134.*

#### Wi-Fi Aware: iOS 26+, and cross-platform pairing does NOT currently work

Available on iPhone 12 and later from iOS 26, implementing the Wi-Fi Alliance
NAN standard rather than proprietary AWDL, and coexisting with infrastructure
Wi-Fi. Requires the `com.apple.developer.wifi-aware` entitlement — a **managed
capability needing Apple's approval**, not a checkbox.

> **This contradicts `SPEC-REVISION` §1.9**, which calls Wi-Fi Aware "the
> preferred interoperable transport" because both platforms implement the same
> standard. They do. **It still does not work between them today.**
>
> Developers report bidirectional failure with current Android flagships: an
> Android publisher's discovery packets are dropped by iOS for a missing DCEA
> attribute, and with iOS publishing, pairing fails with *status 15,
> authentication rejected*. An Apple engineer's response was to take it up with
> the Android vendor. Non-phone hardware *has* interoperated — an ESP32-C5 pairs
> with an iPhone — but it needs mandatory six-digit PIN pairing, DNS-SD service
> naming, and WPA3-PSK-equivalent security that is an iOS-specific requirement.
>
> **So BLE with the overflow-area workaround is currently the more reliable
> cross-platform path, and Wi-Fi Aware is the higher-bandwidth one to adopt when
> it matures.** Plan for it; do not depend on it.

*Evidence: strong on availability and entitlement (Apple docs); strong on the
interop failures (multiple developer reports plus an Apple engineer reply, on
Apple's own forums).*

---

## 7.10 Connectivity tiers

Route selection is the same algorithm in all three cases; only which transports exist changes
(`DECISIONS.md` A5).

| Tier | Situation | Available |
|---|---|---|
| 1 | No internet at all | Local radio only — proximity and courier |
| 2 | Internet present but restricted | Local transports, plus whatever internet actually works |
| 3 | Full internet | Everything, with local preferred and internet last |

**Tier 3 is the common case.** Earlier drafts of this document treated tier 1 as normal, which is
true on an aircraft and false almost everywhere else.

No new mechanism is required: `transportPenalty` already prices a gateway hop at 120 against 0–40 for
local transports (§8.2), so "prefer local, fall back to internet" is an outcome of scoring rather
than a special case. **Tier 2 must never be inferred from network presence** — same SSID does not
mean reachable peer, and client isolation is normal on public and captive networks
(`SPEC-ORIGINAL` §7). Reachability is measured, not assumed.

An implementation MUST surface the live state of each transport separately rather than a single
"offline" indicator (`SPEC-ORIGINAL` §29).

---

## 8. LinkQuality and RouteCost

### 8.1 LinkQuality — the transport contract

`LinkQuality` is a single unsigned byte, 0 (unusable) to 255 (excellent). Every transport MUST map
its own native metric into it. Nothing above the transport layer sees dBm, latency, or any
transport-specific unit (`SPEC-REVISION` §1.8).

**BLE — normative mapping from RSSI:**

```
LinkQuality = clamp( round( (rssi_dBm + 95) × 255 / 65 ), 0, 255 )
```

so −95 dBm or worse maps to 0, −30 dBm or better maps to 255, linear between. A transport MUST
average over `RSSI_SAMPLES` **[policy]** (default 4) rather than using a single instantaneous read.

**Other transports** MUST document their mapping and SHOULD calibrate so that equal LinkQuality
implies comparable delivery probability. Wi-Fi Aware maps from the link metrics its platform exposes;
LAN from latency and loss; a gateway hop from latency and reachability.

### 8.2 The cost formula

```
RouteCost = hopPenalty + weakLinkPenalty + transportPenalty
          + congestionPenalty + batteryPenalty
```

as `SPEC-REVISION` §1.8, computed as uint16 and saturating at 65535.

| Term | Definition | Default **[policy]** |
|---|---|---|
| `hopPenalty` | `HOP_COST × hopCount` | `HOP_COST` = 20 |
| `weakLinkPenalty` | `255 − min(LinkQuality)` over **every** hop | — |
| `transportPenalty` | summed per hop by class | Wi-Fi Aware 0 · LAN 10 · Wi-Fi Direct 20 · BLE 40 · gateway 120 |
| `congestionPenalty` | 0–100, from each relay's queue depth | 0 |
| `batteryPenalty` | 0–60, from each relay's own power state | 0 |

The gateway penalty is high deliberately: that hop discloses the recipient's network address to a
relay (`DECISIONS.md` D7), so it should lose to any local path that exists.

### 8.3 Weakest link, not strongest first hop

**`weakLinkPenalty` uses the minimum LinkQuality across the whole route.** A route is only as good as
its worst hop, and selecting on the strongest first hop produces worse routes. This is the single
most important property in §8 and `vectors/04-route-cost.json` exists to enforce it.

The term is applied **exactly once, by the originator**, from the `minLinkQuality` carried in the
`ROUTE_RESPONSE`. Applying it per hop would compound the penalty and defeat the purpose.

### 8.4 Accumulation along the reverse path

The destination initialises:

```
minLinkQuality    = LinkQuality of the hop the query arrived on
accumulatedPenalty = 0
hopCount          = 0
```

Each node forwarding a `ROUTE_RESPONSE` then:

```
minLinkQuality      = min(minLinkQuality, LinkQuality of the link it arrived on)
accumulatedPenalty += HOP_COST + transportPenalty(that link)
                      + congestionPenalty(self) + batteryPenalty(self)
hopCount           += 1
```

and the originator finally computes:

```
RouteCost = accumulatedPenalty + (255 − minLinkQuality)
```

Lower cost wins. On a tie the originator SHOULD prefer fewer hops, then the earlier response.

These fields are unauthenticated (§6.3). A hostile relay can misreport them to attract or repel
traffic. It cannot forge the MAC, so it cannot invent a destination.

---

## 9. Policy table

Every value here is **SHOULD**, not MUST. They are expected to change empirically
(`DECISIONS.md` D1). An implementation MUST read them from configuration, MUST NOT hardcode them, and
MUST NOT reject a peer for holding different values.

| Constant | Default | Notes |
|---|---:|---|
| `EPOCH_SECONDS` | 600 | Token epoch; ±1 accepted, so 30 min useful life |
| `ADVERT_ROTATE` | 900 s | Ephemeral advertisement ID rotation |
| `PRESENCE_CACHE_TTL` | 900 s | Cached presence entry lifetime |
| `QUERY_STATE_TTL` | 10 s | Reverse-path entry |
| `ROUTE_STATE_TTL` | 15 s | Held route |
| `SEEN_QUERY_TTL` | 60 s | Duplicate suppression window |
| `RING_BASE` | 700 ms | Expanding-ring timeout base |
| `MAX_DISCOVERY_HOPS` | 3 | Discovery scope |
| `MAX_ACCEPTED_HOP_LIMIT` | 8 | Envelope relay ceiling, enforced locally |
| `MAX_ENVELOPE_BYTES` | 32768 | Checked before allocation |
| `MAX_FRAGMENTS` | 16 | Checked before reassembly |
| `MAX_DECOMPRESSED` | 32768 | Enforced *during* streaming decompression |
| `PADDING_BUCKETS` | 256 / 1K / 4K / 16K / 32K | Mandatory |
| `MAX_RELAY_LIFETIME` | 144 buckets (24 h) | Relay retention only |
| Sender retention | indefinite | Own outbox; not a relay limit |
| `MAX_RELAY_STORAGE` | 25–100 MB | User-configurable |
| `MAX_PACKETS_PER_ENCOUNTER` | 64 | |
| `MAX_BYTES_PER_ENCOUNTER` | 1 MB | |
| `MAX_INVENTORY_ENTRIES` | 128 | |
| `MAX_GROUP_MEMBERS` | 6 | Enforced; fan-out cost is N× (§5.2). Deferred past v1 |
| `PRESENCE_SET_SIZE` | 32 | Fixed; padded or sampled so contact count never leaks |
| `BEACON_ADVERT_INTERVAL` | 1000 ms | Persistent-node continuous advertising (§7.8) |
| `HOP_COST` | 20 | Route cost |
| `RSSI_SAMPLES` | 4 | Averaging window |

**`MAX_DISCOVERY_HOPS` (3) and `MAX_ACCEPTED_HOP_LIMIT` (8) differ deliberately** and are not in
conflict. Discovery is bounded tightly because it floods; an already-routed or courier-carried
envelope may legitimately pass through more nodes over a 24-hour life.

**On changing these.** The wire carries the value; the node carries the policy. A default can move
with no version bump and no flag day, and mixed-version nodes interoperate — which is the whole
reason for this table. What a node MUST NOT do is trust a peer's value: every relay enforces its own
ceilings, or one permissive build sets `hopLimit` to 200 and conscripts everyone else.

---

*PROTOCOL.md v1 draft complete. `THREAT-WALKTHROUGH.md` follows: per packet type, precisely what a
passive relay learns — including the places where the honest answer is "more than we would like."*


